Hosted with nbsanity. See source notebook on GitHub.

fh-heroicons

The goal is to have a Python/FastHTML package to use heroicons in FastHTML apps.

The endgoal is to have an API like this:

from fh_heroicons import Heroicon

Heroicon("academic-cap")

A potential plan is the following:

The icons are all hosted in the github repo of heroicons in the src folder as svgs. It should be possible to retrieve them (either completely or at “runtime”, e.g. if the icon is first used), store them locally and then load the svg based on the string that is passed to the Heroicon function (I think it should be a function and not an actual class tbh).

Things I need to figure out:

  • Is it clever to download all of the icons at once or is it better to just cache them locally, after I used them for the first time?
  • How can I convert the “html” svg to a ft svg?
  • Is it better to just save the d content, since everything else won’t change from icon to icon anyway?
svg_ex = """<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.25933 10.1466C3.98688 12.2307 3.82139 14.3483 3.76853 16.494C6.66451 17.703 9.41893 19.1835 12 20.9036C14.5811 19.1835 17.3355 17.703 20.2315 16.494C20.1786 14.3484 20.0131 12.2307 19.7407 10.1467M4.25933 10.1466C3.38362 9.8523 2.49729 9.58107 1.60107 9.3337C4.84646 7.05887 8.32741 5.0972 12 3.49255C15.6727 5.0972 19.1536 7.05888 22.399 9.33371C21.5028 9.58109 20.6164 9.85233 19.7407 10.1467M4.25933 10.1466C6.94656 11.0499 9.5338 12.1709 12.0001 13.4886C14.4663 12.1709 17.0535 11.0499 19.7407 10.1467M6.75 15C7.16421 15 7.5 14.6642 7.5 14.25C7.5 13.8358 7.16421 13.5 6.75 13.5C6.33579 13.5 6 13.8358 6 14.25C6 14.6642 6.33579 15 6.75 15ZM6.75 15V11.3245C8.44147 10.2735 10.1936 9.31094 12 8.44329M4.99264 19.9926C6.16421 18.8211 6.75 17.2855 6.75 15.75V14.25" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>"""

AI Prompt

can you explain to me the different parts of this example svg? how do svgs work?

AI Response

Let’s break down this SVG (Scalable Vector Graphics) example piece by piece:

  1. First, let’s look at the outer <svg> tag:
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
  • width="24" height="24": Sets the display size
  • viewBox="0 0 24 24": Defines the coordinate system (starts at 0,0 and extends to 24,24)
  • fill="none": Default fill color for shapes inside
  • xmlns: Declares this is an SVG XML namespace

Would you like me to explain the path data next? Or would you prefer to explore another aspect of the SVG structure first?

AI Prompt

explain the path data please

AI Response

The path data is defined in the d attribute of the <path> element. Let’s look at what these commands mean:

<path d="M4.25933 10.1466 C3.98688 12.2307 3.82139 14.3483 3.76853 16.494 ..." />

The path data uses commands (letters) followed by coordinates: - M means “Move to” - it starts a new sub-path at the given coordinates (4.25933, 10.1466) - C means “Cubic Bézier curve” - it creates a curve using control points - L (which appears later) means “Line to” - draws straight line to coordinates - Capital letters (like M) use absolute coordinates - Lowercase letters (like m) use relative coordinates

Would you like me to explain more about: 1. How Bézier curves work with their control points 2. The difference between absolute and relative coordinates 3. Other common path commands 4. Or something else?

AI Prompt

no that’s fine, thank you. This is all I need to understand. So the svg xml tag basically defines a svg object and the path tag defines a part of this svg like a div is part of a html document, correct?

AI Response

Yes, that’s a good analogy! Just like how HTML documents can contain multiple divs, an SVG can contain multiple elements like path, circle, rect, etc. The path is probably the most flexible element since it can create any shape through its commands.

The svg tag creates the “canvas” or container where all these elements will be drawn, defining things like size and coordinate system, while the elements inside (like path) define what actually gets drawn.

Would you like to know about other SVG elements besides path, or do you have everything you need for now?

from fasthtml.common import *
from fasthtml.svg import Svg
from fasthtml.svg import Path as SPath
pd = "M4.25933 10.1466C3.98688 12.2307 3.82139 14.3483 3.76853 16.494C6.66451 17.703 9.41893 19.1835 12 20.9036C14.5811 19.1835 17.3355 17.703 20.2315 16.494C20.1786 14.3484 20.0131 12.2307 19.7407 10.1467M4.25933 10.1466C3.38362 9.8523 2.49729 9.58107 1.60107 9.3337C4.84646 7.05887 8.32741 5.0972 12 3.49255C15.6727 5.0972 19.1536 7.05888 22.399 9.33371C21.5028 9.58109 20.6164 9.85233 19.7407 10.1467M4.25933 10.1466C6.94656 11.0499 9.5338 12.1709 12.0001 13.4886C14.4663 12.1709 17.0535 11.0499 19.7407 10.1467M6.75 15C7.16421 15 7.5 14.6642 7.5 14.25C7.5 13.8358 7.16421 13.5 6.75 13.5C6.33579 13.5 6 13.8358 6 14.25C6 14.6642 6.33579 15 6.75 15ZM6.75 15V11.3245C8.44147 10.2735 10.1936 9.31094 12 8.44329M4.99264 19.9926C6.16421 18.8211 6.75 17.2855 6.75 15.75V14.25"

icon = Svg(width="24", height="24", viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
    SPath(d=pd, stroke="#0F172A", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round")
)
show(icon)

Okay so showing an icon works even in solveit, that’s neat.

def Heroicon(icon_str: str) -> FT:
    pd = "M4.25933 10.1466C3.98688 12.2307 3.82139 14.3483 3.76853 16.494C6.66451 17.703 9.41893 19.1835 12 20.9036C14.5811 19.1835 17.3355 17.703 20.2315 16.494C20.1786 14.3484 20.0131 12.2307 19.7407 10.1467M4.25933 10.1466C3.38362 9.8523 2.49729 9.58107 1.60107 9.3337C4.84646 7.05887 8.32741 5.0972 12 3.49255C15.6727 5.0972 19.1536 7.05888 22.399 9.33371C21.5028 9.58109 20.6164 9.85233 19.7407 10.1467M4.25933 10.1466C6.94656 11.0499 9.5338 12.1709 12.0001 13.4886C14.4663 12.1709 17.0535 11.0499 19.7407 10.1467M6.75 15C7.16421 15 7.5 14.6642 7.5 14.25C7.5 13.8358 7.16421 13.5 6.75 13.5C6.33579 13.5 6 13.8358 6 14.25C6 14.6642 6.33579 15 6.75 15ZM6.75 15V11.3245C8.44147 10.2735 10.1936 9.31094 12 8.44329M4.99264 19.9926C6.16421 18.8211 6.75 17.2855 6.75 15.75V14.25"

    icon = Svg(width="24", height="24", viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        SPath(d=pd, stroke="#0F172A", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round")
    )
    return icon

show(Heroicon("academic-cap"))

Getting icon data

AI Prompt

how do I do a simple http get request using httpx ?

AI Response

Here’s how to make a simple GET request using httpx:

import httpx

# For a simple GET request:
response = httpx.get('https://api.example.com')

# The response object contains the server's response
print(response.status_code)  # e.g., 200 for success
print(response.text)        # response content as text

Would you like me to explain more about: 1. How to add headers or parameters to the request 2. How to handle JSON responses 3. How to use the async version 4. Something else?

import httpx

# For a simple GET request:
response = httpx.get('https://example.com')

# The response object contains the server's response
print(response.status_code)  # e.g., 200 for success
print(response.text)        # response content as text
200
<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
    body {
        background-color: #f0f0f2;
        margin: 0;
        padding: 0;
        font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
        
    }
    div {
        width: 600px;
        margin: 5em auto;
        padding: 2em;
        background-color: #fdfdff;
        border-radius: 0.5em;
        box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
    }
    a:link, a:visited {
        color: #38488f;
        text-decoration: none;
    }
    @media (max-width: 700px) {
        div {
            margin: 0 auto;
            width: auto;
        }
    }
    </style>    
</head>

<body>
<div>
    <h1>Example Domain</h1>
    <p>This domain is for use in illustrative examples in documents. You may use this
    domain in literature without prior coordination or asking for permission.</p>
    <p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

Let’s define a function that creates the correct github url for the icon name.

TODO: add the ability to get the url for different icon variations

def icon_url(icon_name: str):
    return f"https://raw.githubusercontent.com/tailwindlabs/heroicons/refs/heads/master/src/24/outline/{icon_name}.svg"
icon_url("arrow-long-up")
'https://raw.githubusercontent.com/tailwindlabs/heroicons/refs/heads/master/src/24/outline/arrow-long-up.svg'
response = httpx.get(icon_url("arrow-long-up"))
response.status_code, response.text
(200,
 '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M8.25 6.75L12 3M12 3L15.75 6.75M12 3V21" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>\n</svg>\n')
svg_str = response.text

Now let’s define a function that retrieves the path data from the svg string. I could just parse the string with beautifulsoup, but I want to keep the dependencies lean, so let’s use a regexp.

import re

pattern = r'd="(.*?)" '

re.findall(pattern=pattern, string=svg_str)
['M8.25 6.75L12 3M12 3L15.75 6.75M12 3V21']
def path_d(svg_str: str):
    pattern = r'd="(.*?)" '
    finds = re.findall(pattern=pattern, string=svg_str)
    assert len(finds) == 1
    return finds[0]
path_d(svg_str)
'M8.25 6.75L12 3M12 3L15.75 6.75M12 3V21'
def Heroicon(icon_str: str) -> FT:
    p_url = icon_url(icon_str)
    r = httpx.get(p_url)
    if r.status_code == 200:
        pd = path_d(r.text)

    icon = Svg(width="24", height="24", viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        SPath(d=pd, stroke="#0F172A", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round")
    )
    return icon

show(Heroicon("academic-cap"))

This works. Neat. But having the logic for fetching the path_data inside of Heroicon is no good idea. Let’s extract a function for it.

from httpx import HTTPError

def get_icon_data(icon_str: str):
    p_url = icon_url(icon_str)
    r = httpx.get(p_url)
    if r.status_code == 200:
        return path_d(r.text)
    else: 
        raise HTTPError(f"Getting icon data failed with status code: {r.status_code}")
get_icon_data("arrow-long-up")
'M8.25 6.75L12 3M12 3L15.75 6.75M12 3V21'
get_icon_data("askdjaös")
---------------------------------------------------------------------------
HTTPError                                 Traceback (most recent call last)
Cell In[1], line 1
----> 1 get_icon_data("askdjaös")

Cell In[1], line 9, in get_icon_data(icon_str)
      7     return path_d(r.text)
      8 else: 
----> 9     raise HTTPError(f"Getting icon data failed with status code: {r.status_code}")

HTTPError: Getting icon data failed with status code: 404
HTTPError: Getting icon data failed with status code: 404
Traceback (most recent call last):

  File "/usr/local/lib/python3.11/site-packages/IPython/core/interactiveshell.py", line 3579, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)

  File "<ipython-input-1-785dd87fb89e>", line 1, in <module>
    get_icon_data("askdjaös")

  File "<ipython-input-1-94e9c15f5154>", line 9, in get_icon_data
    raise HTTPError(f"Getting icon data failed with status code: {r.status_code}")

httpx.HTTPError: Getting icon data failed with status code: 404
def Heroicon(icon_str: str) -> FT:
    pd = get_icon_data(icon_str)

    icon = Svg(width="24", height="24", viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        SPath(d=pd, stroke="#0F172A", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round")
    )
    return icon

show(Heroicon("academic-cap"))

Caching

This is much better. But now I need some local caching mechanism for the icons.

AI Prompt

Help me brainstorm different caching mechanisms for the icon path data

AI Response

I’ll suggest a few different approaches for caching the icon path data, and we can discuss the pros and cons of each:

  1. Simple Dictionary Cache (In-Memory):
    • Store paths in a dictionary while the program runs
    • Fast access, but cache is lost when program ends
    • Very simple to implement
  2. JSON File Cache:
    • Store paths in a local JSON file
    • Persists between program runs
    • Easy to read/debug manually
  3. SQLite Database:
    • More structured storage
    • Good for larger numbers of icons
    • Built into Python standard library
  4. Filesystem Cache:
    • Save each icon’s path in a separate file
    • Easy to manage individual icons
    • Natural file system organization

Would you like me to explain any of these approaches in more detail, or would you prefer to explore a different caching strategy?

AI Prompt

I guess a simple filesystem based cache is good enough for me.

What I was thinking is that the get_icon_data function checks if there is a .fh-heroicons folder and if there is a file with the icon_name as the name in it. If yes, then the path_data will be read from there. Otherwise we fetch the path_data from the github repo and store it in the correct file in .fh-heroicons.

AI Response

That sounds like a good approach! Let’s break this down into steps:

  1. First, we need to create/check for the .fh-heroicons directory
  2. Then modify get_icon_data to:
    • Check if icon file exists in cache
    • If yes, read from cache
    • If no, fetch from GitHub and save to cache

AI Prompt

So this will be a python module that can be installed via pip. What is the ideal location to put the .fh-heroicons cache folder?

AI Response

For a Python package, there are a few standard locations for cache data. The most widely accepted approach is to use the platform-specific user cache directory, which can be accessed using platformdirs library. This follows OS conventions:

  • On Linux: ~/.cache/fh-heroicons/
  • On macOS: ~/Library/Caches/fh-heroicons/
  • On Windows: C:\Users\<username>\AppData\Local\fh-heroicons\Cache

Would you like me to explain how to implement this using platformdirs, or would you prefer to explore a different approach?

AI Prompt

I was just thinking of putting in the working directory folder of the project that uses the library. that way one might also commit the folder to version control and that’s what other “tools” do to.

AI Response

That’s also a valid approach! Many tools do indeed store their cache/assets in the project directory (like node_modules, .git, etc.). This has several benefits:

  1. Project is self-contained
  2. Cache can be version controlled if desired
  3. Different projects can have different versions of icons
  4. Easy to clean up - just delete the folder
  5. No need for additional dependencies

Would you like to proceed with implementing the .fh-heroicons folder in the project directory? We can start with the function to create/check for the directory.

AI Prompt

How do I get the project directory? because if I just create a .fh-heroicons folder in the module code, it will be created in the module folder, correct?

AI Response

Correct - if you create the folder in the module code directly, it would try to create it in the module’s installation directory, which isn’t what we want.

To get the project directory (where the code using your library is running), you can use:

from pathlib import Path

project_dir = Path.cwd()  # gets the current working directory

This will give you the directory from which the user’s code is being run, not where your module is installed. Would you like me to explain more about working with paths in Python, or shall we proceed with implementing the cache directory creation?

So let’s create a function check_cache that returns True or False based on whether the icon is in the cache. It also creates the .fh-hericons folder, if it doesn’t already exist.

from pathlib import Path

project_dir = Path.cwd()
project_dir
Path('/app/data')
project_dir/".fh-heroicons"
Path('/app/data/.fh-heroicons')

We add a folder argument to the check_cache for testing purposes (see below). Maybe there is a more elegant way to do this.

import os 
from pathlib import Path

cwd = Path.cwd()

FOLDER_NAME = cwd/".fh-heroicons"

def check_cache(icon_name, folder=None):
    folder = folder or FOLDER_NAME
    if not os.path.exists(folder): os.mkdir(folder) 

    if icon_name in os.listdir(folder):
        return True
    return False
check_cache("arrow-long-up")
False

now, let’s test the cwd thingie.

AI Prompt

how can I test this? I want to simulate using this as a python module that is installed in some random folder, but I want to make sure that the folder is installed whereever I currently am.

AI Response

Here is how to test this:

  1. Change in another directory
  2. Set the cwd to be this current directory
  3. Trigger the logic to create the folder.
  4. Check that the folder has been created.

AI Prompt

Write the code for me using a temporary directory

AI Response

Here’s code to test using a temporary directory:

import tempfile
import os
from pathlib import Path

# Create a temporary directory
with tempfile.TemporaryDirectory() as temp_dir:
    # Change to temp directory
    os.chdir(temp_dir)
    
    # Now test your function
    check_cache("some-icon")
    
    # Verify the folder was created
    assert ".fh-heroicons" in os.listdir()
    print(f"Cache folder created in: {temp_dir}")

AI Prompt

But if you look closely, this won’t work, because we define the cwd once when the code above runs. When we change the cwd, check_cache still uses the old cwd. I think this is desirable for real world use, but this let’s our test fail. What should I do about it for testing purposes (for real use, I think this is the correct behaviour)?

AI Response

Ah, good catch! Do the following:

Add a parameter to check_cache for testing:

def check_cache(icon_name, folder=None):
    folder = folder or FOLDER_NAME
    # rest of the code using folder
import tempfile
import os
from pathlib import Path

# Create a temporary directory
with tempfile.TemporaryDirectory() as temp_dir:
    temp_path = Path(temp_dir)
    orig_path = os.getcwd()  # Get original path using os.getcwd() directly
    
    try:
        os.chdir(temp_path)
        f = Path.cwd()/".fh-heroicons"
        
        # Test with explicit folder parameter
        check_cache("some-icon", folder=f)
        
        print(os.listdir())
        # Verify the folder was created
        assert ".fh-heroicons" in os.listdir()
        print(f"Cache folder created in: {temp_dir}")
    finally:
        os.chdir(orig_path)  # This will always execute
['.fh-heroicons']
Cache folder created in: /tmp/tmp0h1w_094

So the test passes. I hope the test logic is sound.

now, let’s add this icon to the cache. We can then reuse the logic in the get_icon_data function

AI Prompt

how can I write a string to a file using python?

AI Response

Here’s how to write a string to a file in Python:

with open('filename.txt', 'w') as f:
    f.write('your string here')

The ‘w’ mode will create the file if it doesn’t exist, or overwrite it if it does. Would you like to know about: 1. Different file modes (‘a’ for append, ‘r’ for read, etc.) 2. How to handle paths properly 3. Error handling for file operations 4. Something else?

icon_name = "arrow-long-up"

icon_data = get_icon_data(icon_name)
icon_data
'M8.25 6.75L12 3M12 3L15.75 6.75M12 3V21'
def save_icon_data(icon_name, icon_data):
    with open(os.path.join(FOLDER_NAME, icon_name), 'w') as f:
        f.write(icon_data)
save_icon_data(icon_name, icon_data)
os.path.exists(os.path.join(FOLDER_NAME, icon_name))
True

This works. neat! Now I need a function to read the icon data in.

with open(os.path.join(FOLDER_NAME, icon_name), 'r') as f:
    icon_data2 = f.read()

icon_data2 == icon_data
True
def read_icon_data(icon_name):
    with open(os.path.join(FOLDER_NAME, icon_name), 'r') as f:
        icon_data = f.read()
    return icon_data
read_icon_data(icon_name) == icon_data
True

So now I should be able to write the logic to retrieve the icon data either from the cache or from github.

Let’s recap: 1. Check if the icon_name is in the cache. 2. if yes, read the data from there and return it. 3. if not, retrieve the data from github, save it in the cache and then return the data.

check_cache?
Signature: check_cache(icon_name, folder=None)
Docstring: <no docstring>
File:      ~/<ipython-input-1-f0f3bf997cdc>
Type:      function
icon_name="academic-cap"

if check_cache(icon_name):
    icon_data = read_icon_data(icon_name)
else: 
    p_url = icon_url(icon_name)
    r = httpx.get(p_url)
    if r.status_code == 200:
        icon_data = path_d(r.text)
        save_icon_data(icon_name, icon_data) #save it to cache
    else: 
        raise HTTPError(f"Getting icon data failed with status code: {r.status_code}")

print(icon_data)
M4.25933 10.1466C3.98688 12.2307 3.82139 14.3483 3.76853 16.494C6.66451 17.703 9.41893 19.1835 12 20.9036C14.5811 19.1835 17.3355 17.703 20.2315 16.494C20.1786 14.3484 20.0131 12.2307 19.7407 10.1467M4.25933 10.1466C3.38362 9.8523 2.49729 9.58107 1.60107 9.3337C4.84646 7.05887 8.32741 5.0972 12 3.49255C15.6727 5.0972 19.1536 7.05888 22.399 9.33371C21.5028 9.58109 20.6164 9.85233 19.7407 10.1467M4.25933 10.1466C6.94656 11.0499 9.5338 12.1709 12.0001 13.4886C14.4663 12.1709 17.0535 11.0499 19.7407 10.1467M6.75 15C7.16421 15 7.5 14.6642 7.5 14.25C7.5 13.8358 7.16421 13.5 6.75 13.5C6.33579 13.5 6 13.8358 6 14.25C6 14.6642 6.33579 15 6.75 15ZM6.75 15V11.3245C8.44147 10.2735 10.1936 9.31094 12 8.44329M4.99264 19.9926C6.16421 18.8211 6.75 17.2855 6.75 15.75V14.25
!ls .fh-heroicons
academic-cap          arrow-long-up      heart_outline
academic-cap_outline  bolt_solid
academic-cap_solid    globe-europe-africa_solid
def get_icon_data(icon_name): 
    if check_cache(icon_name):
        icon_data = read_icon_data(icon_name)
    else: 
        p_url = icon_url(icon_name)

        r = httpx.get(p_url)
        if r.status_code == 200:
            icon_data = path_d(r.text)
            save_icon_data(icon_name, icon_data) #save it to cache
        else: 
            raise HTTPError(f"Getting icon data failed with status code: {r.status_code}")

    return icon_data
get_icon_data("academic-cap")
'M4.25933 10.1466C3.98688 12.2307 3.82139 14.3483 3.76853 16.494C6.66451 17.703 9.41893 19.1835 12 20.9036C14.5811 19.1835 17.3355 17.703 20.2315 16.494C20.1786 14.3484 20.0131 12.2307 19.7407 10.1467M4.25933 10.1466C3.38362 9.8523 2.49729 9.58107 1.60107 9.3337C4.84646 7.05887 8.32741 5.0972 12 3.49255C15.6727 5.0972 19.1536 7.05888 22.399 9.33371C21.5028 9.58109 20.6164 9.85233 19.7407 10.1467M4.25933 10.1466C6.94656 11.0499 9.5338 12.1709 12.0001 13.4886C14.4663 12.1709 17.0535 11.0499 19.7407 10.1467M6.75 15C7.16421 15 7.5 14.6642 7.5 14.25C7.5 13.8358 7.16421 13.5 6.75 13.5C6.33579 13.5 6 13.8358 6 14.25C6 14.6642 6.33579 15 6.75 15ZM6.75 15V11.3245C8.44147 10.2735 10.1936 9.31094 12 8.44329M4.99264 19.9926C6.16421 18.8211 6.75 17.2855 6.75 15.75V14.25'
show(Heroicon("academic-cap"))
show(Heroicon("bolt"))
show(Heroicon("bell"))
show(Heroicon("bell"))

So the caching logic seems to work. Nice!

So if I see this correctly, this works now!

Next up:

  • Passing attributes correctly to the Svg object from the Heroicon function (classes, styles etc.)
  • supporting multiple variants of Heroicons (outline, solid, mini, micro)

The solid icons do sometimes have more than one path object. that’s something I need to keep in mind.

Enable customization

Okay so let’s work on passing attributes down from the Heroicon function to the underlying Svg properly.

Let’s start with size since I think that’s the easiest.

def Heroicon(icon_str: str, size: int = 24) -> FT:
    pd = get_icon_data(icon_str)

    icon = Svg(width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        SPath(d=pd, stroke="#0F172A", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round")
    )
    return icon

show(Heroicon("academic-cap"))
show(Heroicon("academic-cap", size=48))

AI Prompt

The code you see in icon in Heroicon is basically a 1:1 mapping of python to html/xml. What does the stroke attribute of path do? and does it interfere with my plan to let the svg be styled using css?

AI Response

The stroke attribute in SVG defines the color of the line/outline of the path, while stroke-width defines how thick that line is.

Having these attributes hardcoded in the path can indeed interfere with CSS styling, since inline SVG attributes typically have higher specificity than CSS rules. If you want the icons to be fully styleable via CSS, you might want to:

Remove the hardcoded stroke attribute Either leave it without a default color, or Set these properties via CSS classes instead Would you like me to explain more about SVG styling specificity or suggest ways to make the icons more CSS-friendly?

AI Prompt

Yeah so my goal is to be able to fully control the styling/appearance of the svg object with the Heroicon function. As you can see I try to develop a library here…

AI Response

In that case, you’ll want to make the function more configurable. You could add parameters for things like:

  1. stroke_color (or just color)
  2. stroke_width
  3. class_name for adding CSS classes
  4. Maybe even a general attributes parameter for any additional SVG attributes

This way, users of your library can either: - Set the styling directly through the function parameters - Pass CSS classes to style it through their stylesheets - Or both

Would you like to explore how to implement this flexibility in your Heroicon function?

get_icon_data("academic-cap")
'M4.25933 10.1466C3.98688 12.2307 3.82139 14.3483 3.76853 16.494C6.66451 17.703 9.41893 19.1835 12 20.9036C14.5811 19.1835 17.3355 17.703 20.2315 16.494C20.1786 14.3484 20.0131 12.2307 19.7407 10.1467M4.25933 10.1466C3.38362 9.8523 2.49729 9.58107 1.60107 9.3337C4.84646 7.05887 8.32741 5.0972 12 3.49255C15.6727 5.0972 19.1536 7.05888 22.399 9.33371C21.5028 9.58109 20.6164 9.85233 19.7407 10.1467M4.25933 10.1466C6.94656 11.0499 9.5338 12.1709 12.0001 13.4886C14.4663 12.1709 17.0535 11.0499 19.7407 10.1467M6.75 15C7.16421 15 7.5 14.6642 7.5 14.25C7.5 13.8358 7.16421 13.5 6.75 13.5C6.33579 13.5 6 13.8358 6 14.25C6 14.6642 6.33579 15 6.75 15ZM6.75 15V11.3245C8.44147 10.2735 10.1936 9.31094 12 8.44329M4.99264 19.9926C6.16421 18.8211 6.75 17.2855 6.75 15.75V14.25'
def Heroicon(icon_str: str, cls=None, style=None, size: int = 24) -> FT:
    pd = get_icon_data(icon_str)

    icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        SPath(d=pd, stroke="currentColor", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round")
    )
    return icon
show(Heroicon("academic-cap", style="color: red"))
show(Heroicon("academic-cap"))

Now let’s try out styling via classes. Let’s define an icon class.

show(Style(".icon {color: red}"))
show(Heroicon("academic-cap", cls="icon"))
show(Heroicon("academic-cap"))

Okay this apparently works too.

So this should be a nice and basic implementation of heroicons for FastHTML (at least the outline icons).

One obvious improvement would be to also support the solid icons and let the variant be controled via an argument to Heroicon, like Heroicon("academic-cap", variant="solid"). But that’s something for tomorrow.

Now I need to figure out how to procede to turn this into a “real” python package.

I’m considering to use nbdev, but this may be a little overkill (although Jeremy would disagree). Since this is my first python package, maybe go with the standard route of a normal python package.

Implementing variants

Now let’s think about how we could implement icon variants (the solid variant is still missing).

From an api standpoint I think having a variant argument to the Heroicon function is good. Maybe the default should be outline.

then we can pass this argument down to get_icon_data which in turn passes it down to icon_url. Then the url will be switched based on the argument.

Under https://github.com/tailwindlabs/heroicons/blob/master/src/24/solid you can find all the “solid” icons.

A problem is that they now may have multiple “path” elements. That’s currently not supported by our code.

That’s an example of a “solid” icon:

<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z" fill="#0F172A"/>
<path d="M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z" fill="#0F172A"/>
<path d="M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z" fill="#0F172A"/>
</svg>

An Idea that I have is that path_d returns a list of “d” and that we first store every “d” as a newline on disk and we then map over the list of “d” to create a SPath for every “d”.

AI Prompt

What do you think of this plan?

AI Response

That’s a good plan! Here’s why:

  1. Storing each path’s “d” attribute on a new line in the cache file is clean and readable
  2. Having path_d return a list makes it work for both single and multiple path SVGs
  3. Mapping over the list to create multiple SPath elements is elegant and flexible

You’ll need to make these changes: 1. Modify path_d to return a list of all matches instead of assuming there’s just one 2. Update the file read/write functions to handle multiple lines 3. Modify the Heroicon function to map over the paths

Would you like to start implementing any of these changes?

svg_string = """<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z" fill="#0F172A"/>
<path d="M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z" fill="#0F172A"/>
<path d="M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z" fill="#0F172A"/>
</svg>"""    
    
pattern = r'd="(.*?)" '
finds = re.findall(pattern=pattern, string=svg_string)
finds
['M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z',
 'M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z',
 'M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z']
len(finds)
3
def path_d(svg_str: str) -> List[str]:
    pattern = r'd="(.*?)" '
    finds = re.findall(pattern=pattern, string=svg_str)
    return finds

path_d(svg_string)
['M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z',
 'M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z',
 'M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z']

AI Prompt

This was easy

AI Response

Yes, the regexp part was straightforward! Now we just need to:

  1. Update save_icon_data to write each path on a new line:
def save_icon_data(icon_name, icon_data_list):
    with open(os.path.join(FOLDER_NAME, icon_name), 'w') as f:
        f.write('\n'.join(icon_data_list))
  1. Update read_icon_data to return a list of paths:
def read_icon_data(icon_name):
    with open(os.path.join(FOLDER_NAME, icon_name), 'r') as f:
        return f.read().splitlines()

Would you like to test these changes?

def save_icon_data(icon_name, icon_data_list: List[str], variant: str):
    with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'w') as f:
        f.write('\n'.join(icon_data_list))
def read_icon_data(icon_name, variant):
    with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'r') as f:
        return f.read().splitlines()

AI Prompt

Okay, that’s also very easy.

AI Response

Yes! Now comes the interesting part - modifying the Heroicon function to: 1. Accept a variant parameter 2. Create multiple SPath elements for solid icons 3. Use different attributes (like fill instead of stroke) based on the variant

Would you like to tackle that next?

def Heroicon(icon_str: str, cls=None, style=None, size: int = 24, variant: str = "outline") -> FT:
    pds = get_icon_data(icon_str, variant=variant)

    icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        *[SPath(d=pd, stroke="currentColor", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round") for pd in pds]
    )
    return icon
def get_icon_data(icon_name, variant): 
    if check_cache(icon_name):
        icon_data = read_icon_data(icon_name, variant=variant)
    else: 
        p_url = icon_url(icon_name, variant=variant)

        r = httpx.get(p_url)
        if r.status_code == 200:
            icon_data = path_d(r.text)
            save_icon_data(icon_name, icon_data, variant=variant) #save it to cache
        else: 
            raise HTTPError(f"Getting icon data failed with status code: {r.status_code}")

    return icon_data
def icon_url(icon_name: str, variant: str):
    return f"https://raw.githubusercontent.com/tailwindlabs/heroicons/refs/heads/master/src/24/{variant}/{icon_name}.svg"

AI Prompt

Now let’s test it. Give me some test cases

AI Response

Let’s test with a few different scenarios:

# Test outline variant (default)
show(Heroicon("academic-cap"))

# Test solid variant
show(Heroicon("academic-cap", variant="solid"))

# Test with different sizes
show(Heroicon("bell", size=48))

# Test with styling
show(Heroicon("bolt", style="color: red"))

# Test with class
show(Style(".icon-blue { color: blue; }"))
show(Heroicon("heart", cls="icon-blue"))

This will test: 1. Both variants (outline/solid) 2. Size changes 3. Direct styling 4. Class-based styling 5. Different icons

Would you like to run these tests?

# Test outline variant (default)
show(Heroicon("academic-cap"))

# Test solid variant
show(Heroicon("academic-cap", variant="solid"))

# Test with different sizes
show(Heroicon("bell", size=48))

# Test with styling
show(Heroicon("bolt", style="color: red"))

# Test with class
show(Style(".icon-blue { color: blue; }"))
show(Heroicon("heart", cls="icon-blue"))
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[1], line 8
      5 show(Heroicon("academic-cap", variant="solid"))
      7 # Test with different sizes
----> 8 show(Heroicon("bell", size=48))
     10 # Test with styling
     11 show(Heroicon("bolt", style="color: red"))

Cell In[1], line 2, in Heroicon(icon_str, cls, style, size, variant)
      1 def Heroicon(icon_str: str, cls=None, style=None, size: int = 24, variant: str = "outline") -> FT:
----> 2     pds = get_icon_data(icon_str, variant=variant)
      4     icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
      5         *[SPath(d=pd, stroke="currentColor", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round") for pd in pds]
      6     )
      7     return icon

Cell In[1], line 3, in get_icon_data(icon_name, variant)
      1 def get_icon_data(icon_name, variant): 
      2     if check_cache(icon_name):
----> 3         icon_data = read_icon_data(icon_name, variant=variant)
      4     else: 
      5         p_url = icon_url(icon_name, variant=variant)

Cell In[1], line 2, in read_icon_data(icon_name, variant)
      1 def read_icon_data(icon_name, variant):
----> 2     with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'r') as f:
      3         return f.read().splitlines()

File /usr/local/lib/python3.11/site-packages/IPython/core/interactiveshell.py:324, in _modified_open(file, *args, **kwargs)
    317 if file in {0, 1, 2}:
    318     raise ValueError(
    319         f"IPython won't let you open fd={file} by default "
    320         "as it is likely to crash IPython. If you know what you are doing, "
    321         "you can use builtins' open."
    322     )
--> 324 return io_open(file, *args, **kwargs)

FileNotFoundError: [Errno 2] No such file or directory: '/app/data/.fh-heroicons/bell_outline'
FileNotFoundError: [Errno 2] No such file or directory: '/app/data/.fh-heroicons/bell_outline'
Traceback (most recent call last):

  File "/usr/local/lib/python3.11/site-packages/IPython/core/interactiveshell.py", line 3579, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)

  File "<ipython-input-1-dc458cc93071>", line 8, in <module>
    show(Heroicon("bell", size=48))
         ^^^^^^^^^^^^^^^^^^^^^^^^^

  File "<ipython-input-1-2ec03ea245cc>", line 2, in Heroicon
    pds = get_icon_data(icon_str, variant=variant)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  File "<ipython-input-1-65e02b88a5bb>", line 3, in get_icon_data
    icon_data = read_icon_data(icon_name, variant=variant)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  File "<ipython-input-1-0f8916ad0bf4>", line 2, in read_icon_data
    with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'r') as f:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  File "/usr/local/lib/python3.11/site-packages/IPython/core/interactiveshell.py", line 324, in _modified_open
    return io_open(file, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

FileNotFoundError: [Errno 2] No such file or directory: '/app/data/.fh-heroicons/bell_outline'
def check_cache(icon_name, variant, folder=None):
    folder = folder or FOLDER_NAME
    if not os.path.exists(folder): os.mkdir(folder) 

    if f"{icon_name}_{variant}" in os.listdir(folder):
        return True
    return False
!ls .fh-heroicons/
academic-cap          arrow-long-up  bolt_solid
academic-cap_outline  bell       globe-europe-africa_solid
academic-cap_solid    bolt       heart_outline
!rm -rf .fh-heroicons/
# Test outline variant (default)
show(Heroicon("academic-cap"))

# Test solid variant
show(Heroicon("academic-cap", variant="solid"))

# Test with different sizes
show(Heroicon("bell", size=48))

# Test with styling
show(Heroicon("bolt", style="color: red"))

# Test with class
show(Style(".icon-blue { color: blue; }"))
show(Heroicon("heart", cls="icon-blue"))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[1], line 2
      1 # Test outline variant (default)
----> 2 show(Heroicon("academic-cap"))
      4 # Test solid variant
      5 show(Heroicon("academic-cap", variant="solid"))

Cell In[1], line 2, in Heroicon(icon_str, cls, style, size, variant)
      1 def Heroicon(icon_str: str, cls=None, style=None, size: int = 24, variant: str = "outline") -> FT:
----> 2     pds = get_icon_data(icon_str, variant=variant)
      4     icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
      5         *[SPath(d=pd, stroke="currentColor", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round") for pd in pds]
      6     )
      7     return icon

Cell In[1], line 2, in get_icon_data(icon_name, variant)
      1 def get_icon_data(icon_name, variant): 
----> 2     if check_cache(icon_name):
      3         icon_data = read_icon_data(icon_name, variant=variant)
      4     else: 

TypeError: check_cache() missing 1 required positional argument: 'variant'
TypeError: check_cache() missing 1 required positional argument: 'variant'
Traceback (most recent call last):

  File "/usr/local/lib/python3.11/site-packages/IPython/core/interactiveshell.py", line 3579, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)

  File "<ipython-input-1-dc458cc93071>", line 2, in <module>
    show(Heroicon("academic-cap"))
         ^^^^^^^^^^^^^^^^^^^^^^^^

  File "<ipython-input-1-2ec03ea245cc>", line 2, in Heroicon
    pds = get_icon_data(icon_str, variant=variant)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  File "<ipython-input-1-65e02b88a5bb>", line 2, in get_icon_data
    if check_cache(icon_name):
       ^^^^^^^^^^^^^^^^^^^^^^

TypeError: check_cache() missing 1 required positional argument: 'variant'
def get_icon_data(icon_name, variant): 
    if check_cache(icon_name, variant):
        icon_data = read_icon_data(icon_name, variant=variant)
    else: 
        p_url = icon_url(icon_name, variant=variant)

        r = httpx.get(p_url)
        if r.status_code == 200:
            icon_data = path_d(r.text)
            save_icon_data(icon_name, icon_data, variant=variant) #save it to cache
        else: 
            raise HTTPError(f"Getting icon data failed with status code: {r.status_code}")

    return icon_data
# Test outline variant (default)
show(Heroicon("academic-cap"))

# Test solid variant
show(Heroicon("academic-cap", variant="solid"))

# Test with different sizes
show(Heroicon("bell", size=48))

# Test with styling
show(Heroicon("bolt", style="color: red"))

# Test with class
show(Style(".icon-blue { color: blue; }"))
show(Heroicon("heart", cls="icon-blue"))

AI Prompt

Okay now the caching also works correctly. Do you have a clever idea how to refactor the code?

AI Response

Here are a few potential refactoring suggestions:

  1. Create a PathConfig class/dataclass for variant-specific path attributes:
from dataclasses import dataclass

@dataclass
class PathConfig:
    stroke: bool = True  # True for outline, False for solid
    attributes: dict = field(default_factory=lambda: {
        "stroke": "currentColor",
        "stroke_width": "1.5",
        "stroke_linecap": "round",
        "stroke_linejoin": "round"
    })
  1. Create an IconCache class to handle all caching operations:
class IconCache:
    def __init__(self, cache_dir=".fh-heroicons"):
        self.cache_dir = Path(cache_dir)
        
    def get(self, icon_name, variant):
        # ... cache logic here
    
    def save(self, icon_name, variant, data):
        # ... save logic here
  1. Use a configuration dictionary for variants:
VARIANTS = {
    "outline": PathConfig(stroke=True),
    "solid": PathConfig(stroke=False, attributes={"fill": "currentColor"})
}

Would you like me to elaborate on any of these approaches?

def Heroicon(icon_str: str, cls=None, style=None, size: int = 24, variant: str = "outline") -> FT:
    pds = get_icon_data(icon_str, variant=variant)

    icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        *[SPath(d=pd, fill="currentColor" if variant == "solid" else "none", stroke="currentColor", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round") for pd in pds]
    )
    return icon
# Test outline variant (default)
show(Heroicon("academic-cap"))

# Test solid variant
show(Heroicon("academic-cap", variant="solid"))

# Test with different sizes
show(Heroicon("bell", size=48))

# Test with styling
show(Heroicon("bolt", style="color: red"))

# Test with class
show(Style(".icon-blue { color: blue; }"))
show(Heroicon("heart", cls="icon-blue"))
show(Heroicon("academic-cap", variant="solid", cls="icon-blue"))
show(Heroicon("globe-europe-africa", cls="icon-blue", variant="solid"))

AI Prompt

the solid components are completely filled. That doesn’t look correct. What’s the matter here?

AI Response

Ah yes, I see the issue. For solid icons, we shouldn’t use both stroke and fill - we should only use fill. Currently, we’re applying both:

SPath(d=pd, fill="currentColor" if variant == "solid" else "none", stroke="currentColor", stroke_width="1.5", stroke_linecap="round", stroke_linejoin="round")

This causes the path to be both filled AND stroked, creating that heavy look. Let’s modify the Heroicon function to use either stroke (for outline) or fill (for solid), but not both:

def Heroicon(icon_str: str, cls=None, style=None, size: int = 24, variant: str = "outline") -> FT:
    pds = get_icon_data(icon_str, variant=variant)
    
    path_attrs = {
        "d": None,  # will be set in the list comprehension
        **({"stroke": "currentColor", "stroke_width": "1.5", "stroke_linecap": "round", "stroke_linejoin": "round"} if variant == "outline" else {"fill": "currentColor"})
    }
    
    icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        *[SPath(d=pd, **path_attrs) for pd in pds]
    )
    return icon

Would you like to try this version?

def Heroicon(icon_str: str, cls=None, style=None, size: int = 24, variant: str = "outline") -> FT:
    pds = get_icon_data(icon_str, variant=variant)
    
    path_attrs = {
        **({"stroke": "currentColor", "stroke_width": "1.5", "stroke_linecap": "round", "stroke_linejoin": "round"} if variant == "outline" else {"fill": "currentColor"})
    }
    
    icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        *[SPath(d=pd, **path_attrs) for pd in pds]
    )
    return icon
show(Heroicon("globe-europe-africa", cls="icon-blue", variant="solid"))

AI Prompt

that’s the string for the “globe-europe-africa” icon:

<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2.25C6.61522 2.25 2.25 6.61522 2.25 12C2.25 17.3848 6.61522 21.75 12 21.75C17.3848 21.75 21.75 17.3848 21.75 12C21.75 6.61522 17.3848 2.25 12 2.25ZM8.54688 4.50525C5.71517 5.8121 3.75 8.67655 3.75 12C3.75 16.5563 7.44365 20.25 12 20.25C16.3141 20.25 19.8548 16.9387 20.2191 12.7191L19.7582 12.2582C19.5872 12.0872 19.4449 11.8897 19.3367 11.6734L18.2567 9.5133C18.1304 9.26078 17.7939 9.20616 17.5942 9.4058C17.3818 9.61824 17.0709 9.69881 16.782 9.61627L15.5091 9.25259C15.0257 9.11447 14.524 9.40424 14.402 9.892C14.3109 10.2566 14.4588 10.6392 14.7715 10.8476L15.3582 11.2388C15.9489 11.6326 16.0317 12.4684 15.5297 12.9703L15.3295 13.1705C15.1186 13.3815 15 13.6676 15 13.966V14.3768C15 14.7846 14.8892 15.1847 14.6794 15.5344L13.3648 17.7254C12.9834 18.3611 12.2965 18.75 11.5552 18.75C10.9724 18.75 10.5 18.2776 10.5 17.6948V16.5233C10.5 15.6033 9.93989 14.7759 9.08565 14.4343L8.43151 14.1726C7.44978 13.7799 6.87393 12.7566 7.04776 11.7136L7.05479 11.6714C7.1012 11.393 7.19959 11.1257 7.34482 10.8837L7.43423 10.7347C7.92346 9.91928 8.87244 9.49948 9.80485 9.68597L10.9827 9.92153C11.5574 10.0365 12.124 9.69096 12.285 9.12744L12.4935 8.39774C12.6423 7.87721 12.3991 7.32456 11.9149 7.08245L11.25 6.75L11.159 6.84099C10.7371 7.26295 10.1648 7.5 9.56805 7.5H9.38712C9.13927 7.5 8.90098 7.59905 8.72572 7.7743C8.44225 8.05778 8.00817 8.12907 7.64961 7.94979C7.16435 7.70716 6.98836 7.10278 7.26749 6.63757L8.54688 4.50525Z" fill="#0F172A"/>
</svg>```

I'm afraid I have to get more info about the "path" objects than just the "d" value for the solid icons to work. Maybe I should just get the complete "path" element and parse all of the attributes... or what do you think is the easiest solution?

**AI Response**

Ah yes, I see the issue now! The solid icons use additional attributes like `fill-rule` and `clip-rule` which are important for proper rendering. You have two good options:

1. Parse the complete path element and extract all attributes:
```python
def path_attrs(svg_str: str) -> List[dict]:
    # Use regex or better yet, an XML parser to get all path elements
    # Return list of dicts with all attributes for each path
    # e.g. [{"d": "...", "fill-rule": "evenodd", "clip-rule": "evenodd"}, ...]
  1. Keep different attribute sets for different variants:
SOLID_ATTRS = {
    "fill": "currentColor",
    "fill-rule": "evenodd",
    "clip-rule": "evenodd"
}

OUTLINE_ATTRS = {
    "stroke": "currentColor",
    "stroke-width": "1.5",
    "stroke-linecap": "round",
    "stroke-linejoin": "round"
}

I’d recommend option 1 - parsing the complete path element. It’s more robust as it will preserve any attributes that the original SVG uses. Would you like me to show how to implement that approach?

AI Prompt

Okay, what module do I use for xml parsing? I don’t want to bloat my dependency tree too much …

AI Response

Python’s standard library includes xml.etree.ElementTree, so you don’t need any additional dependencies. It’s lightweight and perfect for this use case:

from xml.etree import ElementTree as ET

def path_attrs(svg_str: str) -> List[dict]:
    root = ET.fromstring(svg_str)
    paths = root.findall(".//path")
    return [path.attrib for path in paths]

This will give you all attributes for each path element. Would you like to try this approach?

from xml.etree import ElementTree as ET
root = ET.fromstring(svg_string)

root
<Element '{http://www.w3.org/2000/svg}svg' at 0x7df53551dda0>
root.findall(".//path")
[]

AI Prompt

this returns an empty list

AI Response

Ah, the issue is with the XML namespace. In SVG files, there’s a namespace (http://www.w3.org/2000/svg) that we need to handle. Here’s how to fix it:

def path_attrs(svg_str: str) -> List[dict]:
    ns = {'svg': 'http://www.w3.org/2000/svg'}
    root = ET.fromstring(svg_str)
    paths = root.findall(".//svg:path", namespaces=ns)
    return [path.attrib for path in paths]

Or alternatively, you can register the namespace:

ET.register_namespace("", "http://www.w3.org/2000/svg")
root = ET.fromstring(svg_str)
paths = root.findall(".//path")

Would you like to try either of these approaches?

ET.register_namespace("", "http://www.w3.org/2000/svg")

ET.fromstring(svg_string).findall(".//path")
[]
svg_string
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z" fill="#0F172A"/>\n<path d="M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z" fill="#0F172A"/>\n<path d="M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z" fill="#0F172A"/>\n</svg>'

AI Prompt

nope. still he same.

AI Response

Let’s try using BeautifulSoup instead - it’s generally more forgiving with XML/HTML parsing and is already in your dependencies (you mentioned beautifulsoup4 was available):

from bs4 import BeautifulSoup

def path_attrs(svg_str: str) -> List[dict]:
    soup = BeautifulSoup(svg_str, 'xml')
    paths = soup.find_all('path')
    return [dict(path.attrs) for path in paths]

This should work more reliably. Would you like to try it?

Note: BeautifulSoup will use the ‘xml’ parser since we’re dealing with SVG files, which is proper XML.

from bs4 import BeautifulSoup

soup = BeautifulSoup(svg_string, 'xml')
paths = soup.find_all('path')

paths
[<path d="M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z" fill="#0F172A"/>,
 <path d="M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z" fill="#0F172A"/>,
 <path d="M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z" fill="#0F172A"/>]
[dict(path.attrs) for path in paths]
[{'d': 'M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z',
  'fill': '#0F172A'},
 {'d': 'M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z',
  'fill': '#0F172A'},
 {'d': 'M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z',
  'fill': '#0F172A'}]
def path_attrs(svg_str: str) -> List[dict]:
    soup = BeautifulSoup(svg_str, 'xml')
    paths = soup.find_all('path')
    return [dict(path.attrs) for path in paths]

path_attrs(svg_string)
[{'d': 'M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z',
  'fill': '#0F172A'},
 {'d': 'M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z',
  'fill': '#0F172A'},
 {'d': 'M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z',
  'fill': '#0F172A'}]
import json

json.dumps(path_attrs(svg_string))
'[{"d": "M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z", "fill": "#0F172A"}, {"d": "M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z", "fill": "#0F172A"}, {"d": "M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z", "fill": "#0F172A"}]'

AI Prompt

and then I would just save the json string for every icon to the disk for caching, right?

AI Response

Yes, exactly! That’s a clean solution because:

  1. JSON is a text format, so it’s easy to read/write
  2. It preserves all path attributes exactly as they were in the original SVG
  3. It’s easy to parse back into Python dicts

So your save/read functions would look something like:

def save_icon_data(icon_name, path_attrs_list, variant):
    with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'w') as f:
        json.dump(path_attrs_list, f)

def read_icon_data(icon_name, variant):
    with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'r') as f:
        return json.load(f)

Would you like to implement this change?

def save_icon_data(icon_name, path_attrs_list, variant):
    with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'w') as f:
        json.dump(path_attrs_list, f)

def read_icon_data(icon_name, variant):
    with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'r') as f:
        return json.load(f)
!ls -a
.    .fh-heroicons  cms_site     data.db-wal         static
..   .ipython   cookies.txt  movie_reviews       txt_sentoken
.cache   .local     data.db      poldata.README.2.0
.config  .sesskey   data.db-shm  review_polarity.tar.gz
!ls .fh-heroicons/
academic-cap_outline  bell_outline  globe-europe-africa_solid
academic-cap_solid    bolt_outline  heart_outline
!rm -rf .fh-heroicons/

Okay and now I need to change the Heroicon function to spread the path_attrs. and then I need to figure out how to make the styling work properly.

def get_icon_data(icon_name, variant): 
    if check_cache(icon_name, variant):
        icon_data = read_icon_data(icon_name, variant=variant)
    else: 
        p_url = icon_url(icon_name, variant=variant)

        r = httpx.get(p_url)
        if r.status_code == 200:
            icon_data = path_attrs(r.text)
            save_icon_data(icon_name, icon_data, variant=variant) #save it to cache
        else: 
            raise HTTPError(f"Getting icon data failed with status code: {r.status_code}")

    return icon_data
get_icon_data("globe-europe-africa", variant="solid")
[{'fill-rule': 'evenodd',
  'clip-rule': 'evenodd',
  'd': 'M12 2.25C6.61522 2.25 2.25 6.61522 2.25 12C2.25 17.3848 6.61522 21.75 12 21.75C17.3848 21.75 21.75 17.3848 21.75 12C21.75 6.61522 17.3848 2.25 12 2.25ZM8.54688 4.50525C5.71517 5.8121 3.75 8.67655 3.75 12C3.75 16.5563 7.44365 20.25 12 20.25C16.3141 20.25 19.8548 16.9387 20.2191 12.7191L19.7582 12.2582C19.5872 12.0872 19.4449 11.8897 19.3367 11.6734L18.2567 9.5133C18.1304 9.26078 17.7939 9.20616 17.5942 9.4058C17.3818 9.61824 17.0709 9.69881 16.782 9.61627L15.5091 9.25259C15.0257 9.11447 14.524 9.40424 14.402 9.892C14.3109 10.2566 14.4588 10.6392 14.7715 10.8476L15.3582 11.2388C15.9489 11.6326 16.0317 12.4684 15.5297 12.9703L15.3295 13.1705C15.1186 13.3815 15 13.6676 15 13.966V14.3768C15 14.7846 14.8892 15.1847 14.6794 15.5344L13.3648 17.7254C12.9834 18.3611 12.2965 18.75 11.5552 18.75C10.9724 18.75 10.5 18.2776 10.5 17.6948V16.5233C10.5 15.6033 9.93989 14.7759 9.08565 14.4343L8.43151 14.1726C7.44978 13.7799 6.87393 12.7566 7.04776 11.7136L7.05479 11.6714C7.1012 11.393 7.19959 11.1257 7.34482 10.8837L7.43423 10.7347C7.92346 9.91928 8.87244 9.49948 9.80485 9.68597L10.9827 9.92153C11.5574 10.0365 12.124 9.69096 12.285 9.12744L12.4935 8.39774C12.6423 7.87721 12.3991 7.32456 11.9149 7.08245L11.25 6.75L11.159 6.84099C10.7371 7.26295 10.1648 7.5 9.56805 7.5H9.38712C9.13927 7.5 8.90098 7.59905 8.72572 7.7743C8.44225 8.05778 8.00817 8.12907 7.64961 7.94979C7.16435 7.70716 6.98836 7.10278 7.26749 6.63757L8.54688 4.50525Z',
  'fill': '#0F172A'}]
def Heroicon(icon_str: str, cls=None, style=None, size: int = 24, variant: str = "outline") -> FT:
    pas = get_icon_data(icon_str, variant=variant)

    icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        *[SPath(**pa) for pa in pas]
    )
    return icon
show(Heroicon("globe-europe-africa", variant="solid", cls="icon-blue"))

AI Prompt

how do I get the styling to work properly now? especially the color…

AI Response

Ah, we need to replace the hardcoded fill color #0F172A with currentColor in the path attributes when we get them from the SVG. This will allow the color to be controlled by CSS. Here’s how we can modify the path_attrs function:

def path_attrs(svg_str: str) -> List[dict]:
    soup = BeautifulSoup(svg_str, 'xml')
    paths = soup.find_all('path')
    attrs_list = [dict(path.attrs) for path in paths]
    
    # Replace hardcoded colors with currentColor
    for attrs in attrs_list:
        if 'fill' in attrs and attrs['fill'].startswith('#'):
            attrs['fill'] = 'currentColor'
        if 'stroke' in attrs and attrs['stroke'].startswith('#'):
            attrs['stroke'] = 'currentColor'
            
    return attrs_list

This way, when you apply a color through CSS (either via class or style), it will work properly. Would you like to try this?

def path_attrs(svg_str: str) -> List[dict]:
    soup = BeautifulSoup(svg_str, 'xml')
    paths = soup.find_all('path')
    attrs_list = [dict(path.attrs) for path in paths]
    
    # Replace hardcoded colors with currentColor
    for attrs in attrs_list:
        if 'fill' in attrs and attrs['fill'].startswith('#'):
            attrs['fill'] = 'currentColor'
        if 'stroke' in attrs and attrs['stroke'].startswith('#'):
            attrs['stroke'] = 'currentColor'
            
    return attrs_list
show(Heroicon("globe-europe-africa", variant="solid", cls="icon-blue"))

perfect, this works now! nice!

AI Prompt

do you see anything else we need to do or is this production-ready now?

AI Response

Here are a few suggestions to make it production-ready:

  1. Add error handling:
    • Invalid icon names
    • Network issues
    • Invalid cache files
  2. Add input validation:
    • Check if variant is valid (“outline”, “solid”)
    • Validate size is positive
    • Sanitize icon_name
  3. Add documentation:
    • Docstrings
    • Type hints
    • Usage examples
  4. Add tests:
    • Unit tests for each function
    • Integration tests
    • Test edge cases
  5. Package structure:
    • Setup.py/pyproject.toml
    • README
    • License
    • Requirements

Would you like me to help with implementing any of these aspects?

def Heroicon(icon_str: str, cls=None, style=None, size: int = 24, variant: str = "outline") -> FT:
    assert variant in ["solid", "outline"], "Only `solid` or `outline` variants are supported"
    assert size > 0, "size must be positive"


    pas = get_icon_data(icon_str, variant=variant)

    icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        *[SPath(**pa) for pa in pas]
    )
    return icon
Heroicon("academic-cap")
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" height="24" width="24" fill="none"><path d="M4.25933 10.1466C3.98688 12.2307 3.82139 14.3483 3.76853 16.494C6.66451 17.703 9.41893 19.1835 12 20.9036C14.5811 19.1835 17.3355 17.703 20.2315 16.494C20.1786 14.3484 20.0131 12.2307 19.7407 10.1467M4.25933 10.1466C3.38362 9.8523 2.49729 9.58107 1.60107 9.3337C4.84646 7.05887 8.32741 5.0972 12 3.49255C15.6727 5.0972 19.1536 7.05888 22.399 9.33371C21.5028 9.58109 20.6164 9.85233 19.7407 10.1467M4.25933 10.1466C6.94656 11.0499 9.5338 12.1709 12.0001 13.4886C14.4663 12.1709 17.0535 11.0499 19.7407 10.1467M6.75 15C7.16421 15 7.5 14.6642 7.5 14.25C7.5 13.8358 7.16421 13.5 6.75 13.5C6.33579 13.5 6 13.8358 6 14.25C6 14.6642 6.33579 15 6.75 15ZM6.75 15V11.3245C8.44147 10.2735 10.1936 9.31094 12 8.44329M4.99264 19.9926C6.16421 18.8211 6.75 17.2855 6.75 15.75V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

AI Prompt

Create some test cases. Make sure to test the error handling and input validation. Do not use a testing framework.

AI Response

Here’s a simple test suite using basic assertions:

def test_heroicons():
    # Test basic functionality
    assert isinstance(Heroicon("academic-cap"), FT), "Should return FT object"
    assert isinstance(Heroicon("bolt", variant="solid"), FT), "Should return FT object for solid variant"
    
    # Test size validation
    try:
        Heroicon("academic-cap", size=-1)
        assert False, "Should raise error for negative size"
    except AssertionError:
        pass
    
    # Test variant validation
    try:
        Heroicon("academic-cap", variant="invalid")
        assert False, "Should raise error for invalid variant"
    except AssertionError:
        pass
        
    # Test invalid icon name
    try:
        Heroicon("non-existent-icon")
        assert False, "Should raise error for invalid icon name"
    except HTTPError:
        pass
        
    # Test caching
    # First call should create cache
    Heroicon("heart")
    # Check if cache file exists
    assert os.path.exists(os.path.join(FOLDER_NAME, "heart_outline")), "Cache file should exist"
    
    print("All tests passed!")

# Run tests
test_heroicons()

Would you like me to add more test cases or explain any of these in more detail?

def test_heroicons():
    # Test basic functionality
    assert isinstance(Heroicon("academic-cap"), FT), "Should return FT object"
    assert isinstance(Heroicon("bolt", variant="solid"), FT), "Should return FT object for solid variant"
    
    # Test size validation
    try:
        Heroicon("academic-cap", size=-1)
        assert False, "Should raise error for negative size"
    except AssertionError:
        pass
    
    # Test variant validation
    try:
        Heroicon("academic-cap", variant="invalid")
        assert False, "Should raise error for invalid variant"
    except AssertionError:
        pass
        
    # Test invalid icon name
    try:
        Heroicon("non-existent-icon")
        assert False, "Should raise error for invalid icon name"
    except HTTPError:
        pass
        
    # Test caching
    # First call should create cache
    Heroicon("heart")
    # Check if cache file exists
    assert os.path.exists(os.path.join(FOLDER_NAME, "heart_outline")), "Cache file should exist"
    
    print("All tests passed!")

# Run tests
test_heroicons()
All tests passed!
show(Heroicon("academic-cap"))
show(Heroicon("academic-cap", variant="solid"))
show(Heroicon("bolt", variant="solid", style="color: blue"))

AI Prompt

Now please gather all the current code into one code block.

AI Response

Here’s all the code combined into one block:

import os
import json
import httpx
from bs4 import BeautifulSoup
from typing import List
from pathlib import Path
from httpx import HTTPError
from fasthtml.common import *
from fasthtml.svg import Svg
from fasthtml.svg import Path as SPath

# Constants
FOLDER_NAME = Path.cwd()/".fh-heroicons"

def icon_url(icon_name: str, variant: str):
    return f"https://raw.githubusercontent.com/tailwindlabs/heroicons/refs/heads/master/src/24/{variant}/{icon_name}.svg"

def path_attrs(svg_str: str) -> List[dict]:
    soup = BeautifulSoup(svg_str, 'xml')
    paths = soup.find_all('path')
    attrs_list = [dict(path.attrs) for path in paths]
    
    # Replace hardcoded colors with currentColor
    for attrs in attrs_list:
        if 'fill' in attrs and attrs['fill'].startswith('#'):
            attrs['fill'] = 'currentColor'
        if 'stroke' in attrs and attrs['stroke'].startswith('#'):
            attrs['stroke'] = 'currentColor'
            
    return attrs_list

def check_cache(icon_name, variant, folder=None):
    folder = folder or FOLDER_NAME
    if not os.path.exists(folder): os.mkdir(folder) 

    if f"{icon_name}_{variant}" in os.listdir(folder):
        return True
    return False

def save_icon_data(icon_name, path_attrs_list, variant):
    with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'w') as f:
        json.dump(path_attrs_list, f)

def read_icon_data(icon_name, variant):
    with open(os.path.join(FOLDER_NAME, f"{icon_name}_{variant}"), 'r') as f:
        return json.load(f)

def get_icon_data(icon_name, variant): 
    if check_cache(icon_name, variant):
        icon_data = read_icon_data(icon_name, variant=variant)
    else: 
        p_url = icon_url(icon_name, variant=variant)

        r = httpx.get(p_url)
        if r.status_code == 200:
            icon_data = path_attrs(r.text)
            save_icon_data(icon_name, icon_data, variant=variant) #save it to cache
        else: 
            raise HTTPError(f"Getting icon data failed with status code: {r.status_code}")

    return icon_data

def Heroicon(icon_str: str, cls=None, style=None, size: int = 24, variant: str = "outline") -> FT:
    assert variant in ["solid", "outline"], "Only `solid` or `outline` variants are supported"
    assert size > 0, "size must be positive"

    pas = get_icon_data(icon_str, variant=variant)

    icon = Svg(cls=cls, style=style, width=size, height=size, viewBox="0 0 24 24", fill="none", xmlns="http://www.w3.org/2000/svg")(
        *[SPath(**pa) for pa in pas]
    )
    return icon

def test_heroicons():
    # Test basic functionality
    assert isinstance(Heroicon("academic-cap"), FT), "Should return FT object"
    assert isinstance(Heroicon("bolt", variant="solid"), FT), "Should return FT object for solid variant"
    
    # Test size validation
    try:
        Heroicon("academic-cap", size=-1)
        assert False, "Should raise error for negative size"
    except AssertionError:
        pass
    
    # Test variant validation
    try:
        Heroicon("academic-cap", variant="invalid")
        assert False, "Should raise error for invalid variant"
    except AssertionError:
        pass
        
    # Test invalid icon name
    try:
        Heroicon("non-existent-icon")
        assert False, "Should raise error for invalid icon name"
    except HTTPError:
        pass
        
    # Test caching
    # First call should create cache
    Heroicon("heart")
    # Check if cache file exists
    assert os.path.exists(os.path.join(FOLDER_NAME, "heart_outline")), "Cache file should exist"
    
    print("All tests passed!")

This includes all the functionality we’ve developed: - URL construction - SVG parsing with BeautifulSoup - Caching mechanism - Path attribute handling - Main Heroicon function - Tests

Would you like me to add docstrings or make any other improvements?

AI Prompt

Can I use the tests you wrote with something like pytest or any other unit test framework? if yes, how?

AI Response

Yes, you can easily convert these tests to pytest format. Here’s how:

import pytest

def test_basic_functionality():
    assert isinstance(Heroicon("academic-cap"), FT)
    assert isinstance(Heroicon("bolt", variant="solid"), FT)

def test_size_validation():
    with pytest.raises(AssertionError):
        Heroicon("academic-cap", size=-1)

def test_variant_validation():
    with pytest.raises(AssertionError):
        Heroicon("academic-cap", variant="invalid")

def test_invalid_icon():
    with pytest.raises(HTTPError):
        Heroicon("non-existent-icon")

def test_caching():
    Heroicon("heart")
    assert os.path.exists(os.path.join(FOLDER_NAME, "heart_outline"))

Key changes: 1. Each test is its own function starting with test_ 2. Using pytest.raises() context manager instead of try/except 3. One assertion per test (better practice)

Save these in a file named test_heroicons.py and run with:

pytest test_heroicons.py

Would you like me to add more test cases or explain the pytest features in more detail?

import pytest

def test_basic_functionality():
    assert isinstance(Heroicon("academic-cap"), FT)
    assert isinstance(Heroicon("bolt", variant="solid"), FT)

def test_size_validation():
    with pytest.raises(AssertionError):
        Heroicon("academic-cap", size=-1)

def test_variant_validation():
    with pytest.raises(AssertionError):
        Heroicon("academic-cap", variant="invalid")

def test_invalid_icon():
    with pytest.raises(HTTPError):
        Heroicon("non-existent-icon")

def test_caching():
    Heroicon("heart")
    assert os.path.exists(os.path.join(FOLDER_NAME, "heart_outline"))

pytest test_heroicons.py
  Cell In[1], line 23
    pytest test_heroicons.py
           ^
SyntaxError: invalid syntax

AI Prompt

and what’s the easiest way to automate the “visual” tests I did with show()?

AI Response

For visual testing of SVGs, you have a few options:

  1. Snapshot testing - Compare the actual SVG output string with a known good version:
def test_svg_output():
    expected_svg = '<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" height="24" width="24" fill="none"><path d="..." stroke="currentColor"></path></svg>'
    assert str(Heroicon("academic-cap")) == expected_svg
  1. Screenshot testing using pytest-playwright:
from playwright.sync_api import expect

def test_visual(page):
    # Render icon in a minimal HTML page
    page.set_content(str(Heroicon("academic-cap")))
    # Compare with stored screenshot
    expect(page).to_have_screenshot("academic-cap.png")
  1. Test specific attributes are present:
def test_svg_attributes():
    icon = Heroicon("academic-cap", cls="icon-blue", size=48)
    assert 'class="icon-blue"' in str(icon)
    assert 'height="48"' in str(icon)
    assert 'width="48"' in str(icon)

Would you like me to explain any of these approaches in more detail?

AI Prompt

Snapshot testing sounds ideal. Please write snapshot tests based on what’s currently implemented - basically the three show(Heroicon(...)) calls above.

AI Response

Here are snapshot tests for the three examples we had:

def test_outline_icon_snapshot():
    expected = '<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" height="24" width="24" fill="none"><path d="M4.25933 10.1466C3.98688 12.2307 3.82139 14.3483 3.76853 16.494C6.66451 17.703 9.41893 19.1835 12 20.9036C14.5811 19.1835 17.3355 17.703 20.2315 16.494C20.1786 14.3484 20.0131 12.2307 19.7407 10.1467M4.25933 10.1466C3.38362 9.8523 2.49729 9.58107 1.60107 9.3337C4.84646 7.05887 8.32741 5.0972 12 3.49255C15.6727 5.0972 19.1536 7.05888 22.399 9.33371C21.5028 9.58109 20.6164 9.85233 19.7407 10.1467M4.25933 10.1466C6.94656 11.0499 9.5338 12.1709 12.0001 13.4886C14.4663 12.1709 17.0535 11.0499 19.7407 10.1467M6.75 15C7.16421 15 7.5 14.6642 7.5 14.25C7.5 13.8358 7.16421 13.5 6.75 13.5C6.33579 13.5 6 13.8358 6 14.25C6 14.6642 6.33579 15 6.75 15ZM6.75 15V11.3245C8.44147 10.2735 10.1936 9.31094 12 8.44329M4.99264 19.9926C6.16421 18.8211 6.75 17.2855 6.75 15.75V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>'
    assert to_xml(Heroicon("academic-cap")) == expected

def test_solid_icon_snapshot():
    expected = '<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" height="24" width="24" fill="none"><path d="M11.6998 2.80529C11.8912 2.72164 12.1089 2.72164 12.3003 2.80529C16.0192 4.43011 19.5437 6.41637 22.8295 8.71956C23.0673 8.88623 23.1875 9.1752 23.1381 9.46135C23.0887 9.7475 22.8785 9.97941 22.5986 10.0567C21.9137 10.2457 21.2347 10.4494 20.5618 10.6663C17.8307 11.5471 15.2018 12.6554 12.6972 13.9688L12.6939 13.9705C12.5803 14.0301 12.467 14.09 12.354 14.1504C12.1331 14.2684 11.8679 14.2684 11.6471 14.1504C11.533 14.0895 11.4186 14.0289 11.3039 13.9688C10.0655 13.3193 8.79658 12.7201 7.5 12.1736V11.95C7.5 11.8186 7.56742 11.702 7.67173 11.6389C9.17685 10.727 10.7294 9.88565 12.3247 9.11936C12.6981 8.94002 12.8554 8.49195 12.6761 8.11858C12.4967 7.7452 12.0486 7.58791 11.6753 7.76725C10.036 8.55463 8.44086 9.41909 6.89449 10.3559C6.44111 10.6306 6.13632 11.0801 6.03607 11.5838C5.18115 11.2549 4.31499 10.9486 3.43829 10.6659C2.76546 10.4489 2.08644 10.2457 1.40154 10.0567C1.12162 9.9794 0.911461 9.74749 0.86204 9.46134C0.812619 9.17519 0.932824 8.88622 1.17061 8.71955C4.45645 6.41636 7.98097 4.43011 11.6998 2.80529Z" fill="currentColor"></path><path d="M13.0609 15.4734C15.4997 14.1703 18.0621 13.0687 20.7258 12.1906C20.8601 13.6054 20.9458 15.0343 20.9813 16.4755C20.9889 16.7847 20.8059 17.0669 20.5205 17.1861C17.6693 18.3764 14.9574 19.834 12.4159 21.5277C12.1641 21.6955 11.836 21.6955 11.5841 21.5277C9.04267 19.834 6.33073 18.3764 3.4796 17.1861C3.19416 17.0669 3.01116 16.7847 3.01878 16.4755C3.05429 15.0342 3.14001 13.6052 3.27427 12.1903C4.19527 12.4938 5.10415 12.8242 6 13.1803V14.4507C5.55165 14.71 5.25 15.1948 5.25 15.75C5.25 16.2453 5.49008 16.6846 5.86022 16.9577C5.7707 17.3383 5.63822 17.7108 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C7.06243 18.2137 7.24612 17.6729 7.36014 17.1207C7.88449 16.887 8.25 16.3612 8.25 15.75C8.25 15.1948 7.94835 14.71 7.5 14.4507V13.8059C8.6714 14.3177 9.81885 14.8743 10.9402 15.4734C11.6028 15.8274 12.3983 15.8274 13.0609 15.4734Z" fill="currentColor"></path><path d="M4.46222 19.4623C4.88136 19.0432 5.21502 18.5711 5.46277 18.0675C5.91546 18.2811 6.36428 18.5017 6.8091 18.7289C6.49055 19.3768 6.06164 19.9842 5.52288 20.523C5.22999 20.8158 4.75512 20.8158 4.46222 20.523C4.16933 20.2301 4.16933 19.7552 4.46222 19.4623Z" fill="currentColor"></path></svg>'
    assert to_xml(Heroicon("academic-cap", variant="solid")) == expected

def test_styled_icon_snapshot():
    expected = '<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" height="24" width="24" fill="none" style="color: blue"><path d="M14.6152 1.59492C14.9164 1.76287 15.0643 2.1146 14.9736 2.44734L12.9819 9.75H20.25C20.5486 9.75 20.8188 9.92718 20.9378 10.2011C21.0569 10.475 21.0021 10.7934 20.7983 11.0117L10.2983 22.2617C10.063 22.5139 9.68601 22.573 9.38478 22.4051C9.08354 22.2371 8.93567 21.8854 9.02641 21.5527L11.018 14.25H3.74999C3.45134 14.25 3.18115 14.0728 3.06213 13.7989C2.9431 13.525 2.99792 13.2066 3.20169 12.9883L13.7017 1.73826C13.937 1.48613 14.314 1.42698 14.6152 1.59492Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>'
    assert to_xml(Heroicon("bolt", variant="solid", style="color: blue")) == expected

A few notes: 1. These tests verify the exact output HTML string 2. The expected strings include all attributes and path data 3. They test different variants (outline/solid) and styling