Compare commits

...

5 Commits

5 changed files with 580 additions and 87 deletions

View File

@ -23,3 +23,20 @@ I always use uv to install stuff, not pip
use `uv add <package>` to install instead of `pip install <package>`.
use `uv run <command>` to run commands, e.g. `uv run python hello.py`.
## Coding
For dialogs and snackbars, do the following:
```python
dialog = < create dialog >
# to show
page.open(dialog)
# to close
page.close(dialog)
```
Do NOT use `page.show_snack_bar or page.dialog = ...` as those don't exist in recent Flet versions anymore.

125
anki.py Normal file
View File

@ -0,0 +1,125 @@
from result import Result, Ok, Err
import requests
import logging
from typing import List, Dict, Tuple, Any
logger = logging.getLogger("flashcard-creator")
class AnkiError(Exception):
"""Base class for Anki-related errors"""
pass
class ConnectionError(AnkiError):
"""Error connecting to AnkiConnect"""
pass
class DeckError(AnkiError):
"""Error related to deck operations"""
pass
class CardError(AnkiError):
"""Error related to card operations"""
pass
class AnkiConnect:
def __init__(self, url="http://127.0.0.1:8765"):
self.url = url
def get_decks(self) -> Result[List[str], AnkiError]:
"""
Get the list of decks from Anki using AnkiConnect.
Returns Ok(list_of_deck_names) on success, Err(AnkiError) on failure.
"""
try:
response = requests.post(
self.url, json={"action": "deckNames", "version": 6}
)
data = response.json()
return Ok(data.get("result", []))
except requests.exceptions.RequestException as e:
logger.error(f"Connection error getting Anki decks: {e}")
return Err(ConnectionError(f"Failed to connect to Anki: {e}"))
except Exception as e:
logger.error(f"Error getting Anki decks: {e}")
return Err(DeckError(f"Failed to get decks: {e}"))
def create_cards(
self,
deck: str,
cards: List[Dict[str, str]],
source_language: str,
target_language: str,
) -> Result[Tuple[int, List[Dict[str, Any]]], AnkiError]:
"""Create flashcards in Anki using AnkiConnect, adding each note one by one.
Returns:
Ok((successful_count, failed_cards_list)): Where failed_cards_list contains
dictionaries like {'card': original_card_dict, 'error': error_message}.
Err(ConnectionError): If connection to AnkiConnect fails.
Err(CardError): If a non-connection error occurs during the process.
"""
try:
logger.info(f"Preparing to create {len(cards)} Anki cards in deck: {deck}")
failed_cards = []
successful_count = 0
# Add each note individually
for card in cards:
note = {
"deckName": deck,
"modelName": "Basic", # Using the basic model
"fields": {"Front": card["front"], "Back": card["back"]},
"options": {"allowDuplicate": False},
"tags": [f"{source_language}-{target_language}"],
}
# Send request for a single note
response = requests.post(
self.url,
json={"action": "addNote", "version": 6, "params": {"note": note}},
)
data = response.json()
# Check if the note was added successfully
if data.get("error") is not None or data.get("result") is None:
error_msg = data.get("error", "Unknown error")
failed_cards.append({"card": card, "error": error_msg})
logger.warning(
f"Failed to add card: {card['front']} - Error: {error_msg}"
)
else:
successful_count += 1
logger.debug(f"Successfully added card: {card['front']}")
# Report results
if failed_cards:
logger.warning(
f"Some cards failed to be added to Anki. Added {successful_count}/{len(cards)}"
)
elif successful_count > 0:
logger.info(f"Successfully added all {successful_count} cards to Anki")
else:
logger.info(
"No cards were added."
) # Should ideally not happen if input list wasn't empty
# Always return Ok with success count and failed list (which might be empty)
# The caller can decide if the presence of failed_cards constitutes an overall "error" state for the UI
return Ok((successful_count, failed_cards))
except requests.exceptions.RequestException as e:
logger.error(f"Connection error creating Anki cards: {e}")
return Err(ConnectionError(f"Failed to connect to Anki: {e}"))
except Exception as e:
logger.error(f"Error creating Anki cards: {e}")
return Err(CardError(f"Failed to create cards: {e}"))

504
main.py
View File

@ -6,6 +6,9 @@ import os
import csv
import logging
from typing import Dict, List, Any
from result import Ok, Result
from anki import AnkiError
# Set up logging
logging.basicConfig(
@ -16,12 +19,16 @@ logger = logging.getLogger("flashcard-creator")
class FlashcardCreator:
def __init__(self):
# AnkiConnect configuration
self.anki_connect_url = "http://127.0.0.1:8765"
# Anki configuration
from anki import AnkiConnect
self.anki = AnkiConnect()
# File picker
self.file_picker = ft.FilePicker()
self.is_processing = False
# LiteLLM configuration
self.llm_provider = "openai"
self.llm_model = "gpt-3.5-turbo"
@ -59,7 +66,11 @@ class FlashcardCreator:
self.llm_provider = settings.get("provider", self.llm_provider)
self.llm_model = settings.get("model", self.llm_model)
self.base_url = settings.get("base_url", self.base_url)
self.api_key = settings.get("api_key", self.api_key)
# Load provider-specific settings
provider_settings = settings.get(self.llm_provider, {})
self.llm_model = provider_settings.get("model", self.llm_model)
self.base_url = provider_settings.get("base_url", self.base_url)
self.api_key = provider_settings.get("api_key", self.api_key)
except:
pass
@ -138,19 +149,25 @@ class FlashcardCreator:
width=page.width, # Make table as wide as the window
)
# Initialize the flashcard table with the "add new card" row
self.add_new_card_row()
# Wrap the table in a Container with specific height to ensure visibility
flashcard_table_container = ft.Container(
content=ft.Column(
content=ft.Row(
controls=[
ft.Column(
expand=True,
controls=[self.flashcard_table],
scroll=ft.ScrollMode.ALWAYS,
)
],
expand=True,
controls=[self.flashcard_table],
scroll=ft.ScrollMode.ALWAYS,
),
padding=10,
expand=True,
# height=400,
border=ft.border.all(1, ft.colors.OUTLINE),
border_radius=5,
width=page.width,
)
# Add file picker to page overlay
@ -209,21 +226,57 @@ class FlashcardCreator:
settings_dir = os.path.join(os.path.expanduser("~"), ".flashcard_creator")
os.makedirs(settings_dir, exist_ok=True)
with open(os.path.join(settings_dir, "settings.json"), "w") as f:
print(self.base_url)
json.dump(
{
"provider": self.llm_provider,
"model": self.llm_model,
"base_url": self.base_url,
"api_key": self.api_key,
},
f,
)
settings_path = os.path.join(settings_dir, "settings.json")
settings = {}
if os.path.exists(settings_path):
try:
with open(settings_path, "r") as f:
settings = json.load(f)
except:
pass
# Update provider-specific settings
settings[self.llm_provider] = {
"model": self.llm_model,
"base_url": self.base_url,
"api_key": self.api_key,
}
# Save the currently selected provider
settings["provider"] = self.llm_provider
with open(settings_path, "w") as f:
json.dump(settings, f, indent=4)
e.page.close(dialog)
e.page.update()
# Function to update displayed settings when provider changes
def on_provider_change(e):
selected_provider = provider_dropdown.value
# Load settings from file
settings_path = os.path.join(
os.path.expanduser("~"), ".flashcard_creator", "settings.json"
)
settings = {}
if os.path.exists(settings_path):
try:
with open(settings_path, "r") as f:
settings = json.load(f)
except:
pass
# Get selected provider's settings or empty dict if not found
provider_settings = settings.get(selected_provider, {})
# Update the input fields with the provider's settings
model_input.value = provider_settings.get("model", "")
base_url_input.value = provider_settings.get("base_url", "")
api_key_input.value = provider_settings.get("api_key", "")
# Update the dialog
e.page.update()
# Load settings if they exist
settings_path = os.path.join(
os.path.expanduser("~"), ".flashcard_creator", "settings.json"
@ -235,10 +288,13 @@ class FlashcardCreator:
settings = json.load(f)
print(settings)
self.llm_provider = settings.get("provider", self.llm_provider)
self.llm_model = settings.get("model", self.llm_model)
self.base_url = settings.get("base_url", self.base_url)
self.api_key = settings.get("api_key", self.api_key)
except:
# Load provider-specific settings
provider_settings = settings.get(self.llm_provider, {})
self.llm_model = provider_settings.get("model", self.llm_model)
self.base_url = provider_settings.get("base_url", self.base_url)
self.api_key = provider_settings.get("api_key", self.api_key)
except e:
logger.info(f"Settings couldn't be loaded: {e}")
pass
provider_dropdown = ft.Dropdown(
@ -248,8 +304,10 @@ class FlashcardCreator:
ft.dropdown.Option("anthropic"),
ft.dropdown.Option("vertexai"),
ft.dropdown.Option("huggingface"),
ft.dropdown.Option("deepseek"),
],
value=self.llm_provider,
on_change=on_provider_change, # Add the on_change handler
)
model_input = ft.TextField(label="Model Name", value=self.llm_model)
@ -271,11 +329,16 @@ class FlashcardCreator:
)
e.page.open(dialog)
# e.page.dialog = dialog
# dialog.open = True
e.page.update()
def on_process_text(self, e):
# Prevent multiple simultaneous processing
if self.is_processing:
e.page.open(
ft.SnackBar(content=ft.Text("Processing in progress, please wait..."))
)
return
input_text = self.input_text.value
if not input_text:
e.page.open(
@ -285,11 +348,21 @@ class FlashcardCreator:
logger.info("Processing input text")
# Use LiteLLM to process the text and generate flashcards
flashcards = self.generate_flashcards(input_text, e.page)
self.is_processing = True
self.process_button.disabled = True
e.page.update()
# Update the data table with the generated flashcards
self.update_flashcard_table(flashcards, e.page)
try:
# Use LiteLLM to process the text and generate flashcards
flashcards = self.generate_flashcards(input_text, e.page)
# Update the data table with the generated flashcards
self.update_flashcard_table(flashcards, e.page)
finally:
# Reset processing state and re-enable button
self.is_processing = False
self.process_button.disabled = False
e.page.update()
def generate_flashcards(self, text: str, page: ft.Page = None) -> Dict:
"""Use LiteLLM to generate flashcards from the input text."""
@ -298,9 +371,17 @@ class FlashcardCreator:
# Show progress bar
progress = None
if page:
progress = ft.ProgressBar(width=600)
progress = ft.ProgressBar(width=max(page.width - 40, 200))
page.add(
ft.Column([ft.Text("Generating flashcards...", size=16), progress])
ft.Column(
[
ft.Text(
f"Generating flashcards using {self.llm_provider}/{self.llm_model}",
size=16,
),
progress,
]
)
)
page.update()
@ -324,6 +405,7 @@ class FlashcardCreator:
5. For idiomatic expressions, provide the closest equivalent in the target language
6. Choose content that represents different difficulty levels
7. Ensure translations are accurate and natural-sounding in the target language
8. Make sure to create flashcards for ALL relevant words in the input! If you are unsure, err on the side of too many flashcards.
OUTPUT FORMAT:
Return your response in valid YAML format with the following structure:
@ -354,7 +436,6 @@ class FlashcardCreator:
- "我很高兴认识你": "Es freut mich, dich kennenzulernen"
- "慢走": "Komm gut nach Hause (idiom.)"
```
Here's the text to process:
@ -363,7 +444,20 @@ class FlashcardCreator:
try:
# Set the API key for the selected provider
os.environ[f"{self.llm_provider.upper()}_API_KEY"] = self.api_key
# Load provider-specific settings
settings_path = os.path.join(
os.path.expanduser("~"), ".flashcard_creator", "settings.json"
)
settings = {}
if os.path.exists(settings_path):
with open(settings_path, "r") as f:
try:
settings = json.load(f)
except:
pass
provider_settings = settings.get(self.llm_provider, {})
api_key = provider_settings.get("api_key", "")
os.environ[f"{self.llm_provider.upper()}_API_KEY"] = api_key
logger.info("Sending request to LLM API...")
@ -403,6 +497,12 @@ class FlashcardCreator:
return flashcards
except Exception as e:
logger.error(f"Error generating flashcards: {e}", exc_info=True)
if page:
page.open(
ft.SnackBar(
content=ft.Text(f"Error generating flashcards: {str(e)}")
)
)
return {"name": "", "description": "", "cards": []}
finally:
# Remove progress bar
@ -532,14 +632,19 @@ class FlashcardCreator:
e.page.update()
return
# Add a new row with the entered values
# remove add_new_card row
if self.flashcard_table.rows:
self.flashcard_table.rows.pop()
# add card to table
self.add_row_to_table(front_field.value, back_field.value)
# Clear the input fields
# re-add add_new_card_row to the end
self.add_new_card_row()
front_field.value = ""
back_field.value = ""
# Update the page
e.page.update()
def add_to_anki(self, e):
@ -580,21 +685,80 @@ class FlashcardCreator:
e.page.open(ft.SnackBar(content=ft.Text("Please select a deck")))
return
success = self.create_anki_cards(selected_deck, cards)
# Close the deck selection dialog first
e.page.close(dialog)
e.page.update() # Update to ensure dialog is closed visually
# Show a progress indicator while adding cards
progress_ring = ft.ProgressRing(width=16, height=16, stroke_width=2)
progress_snackbar = ft.SnackBar(
content=ft.Row(
[
progress_ring,
ft.Text(f"Adding cards to {selected_deck}... Please wait."),
]
),
open=True,
duration=None, # Keep open until manually closed or replaced
)
e.page.open(progress_snackbar)
e.page.update()
if success:
e.page.open(
ft.SnackBar(
content=ft.Text(
f"Added {len(cards)} flashcards to {selected_deck}"
try:
result = self.create_anki_cards(selected_deck, cards)
# Close the progress snackbar
e.page.close(progress_snackbar)
e.page.update()
if result.is_ok():
successful_count, failed_cards_list = result.ok_value
total_attempted = len(cards)
if not failed_cards_list:
# All cards added successfully
e.page.open(
ft.SnackBar(
content=ft.Text(
f"Successfully added {successful_count}/{total_attempted} flashcards to {selected_deck}"
),
open=True,
)
)
else:
# Some cards failed, show the failure dialog
logger.warning(
f"Failed to add {len(failed_cards_list)} cards. Opening retry dialog."
)
self.show_failed_cards_dialog(
e.page,
selected_deck,
failed_cards_list,
successful_count,
total_attempted,
)
else: # Handle Err case from create_anki_cards
e.page.open(
ft.SnackBar(
content=ft.Text(
f"An error occurred while adding cards to Anki: {result.err_value}"
),
open=True,
)
)
except Exception as ex: # Catch any unexpected errors during the process
# Close the progress snackbar in case of an error
e.page.close(progress_snackbar)
e.page.update()
logger.error(
f"Unexpected error during Anki card addition: {ex}", exc_info=True
)
else:
e.page.open(
ft.SnackBar(content=ft.Text("Failed to add flashcards to Anki"))
ft.SnackBar(
content=ft.Text(f"An unexpected error occurred: {ex}"),
open=True,
)
)
dialog = ft.AlertDialog(
@ -613,42 +777,23 @@ class FlashcardCreator:
def get_anki_decks(self) -> List[str]:
"""Get the list of decks from Anki using AnkiConnect."""
try:
response = requests.post(
self.anki_connect_url, json={"action": "deckNames", "version": 6}
)
data = response.json()
return data.get("result", [])
except Exception as e:
print(f"Error getting Anki decks: {e}")
result = self.anki.get_decks()
if result.is_ok():
return result.ok_value
else:
logger.error(f"Error getting Anki decks: {result.err_value}")
return []
def create_anki_cards(self, deck: str, cards: List[Dict[str, str]]) -> bool:
"""Create flashcards in Anki using AnkiConnect."""
try:
# Prepare notes for addition
notes = []
for card in cards:
notes.append(
{
"deckName": deck,
"modelName": "Basic", # Using the basic model
"fields": {"Front": card["front"], "Back": card["back"]},
"options": {"allowDuplicate": False},
"tags": [f"{self.source_language}-{self.target_language}"],
}
)
# Add notes to Anki
response = requests.post(
self.anki_connect_url,
json={"action": "addNotes", "version": 6, "params": {"notes": notes}},
)
data = response.json()
return all(id is not None for id in data.get("result", []))
except Exception as e:
print(f"Error creating Anki cards: {e}")
return False
def create_anki_cards(
self, deck: str, cards: List[Dict[str, str]]
) -> Result[None, AnkiError]:
"""Create flashcards in Anki using Anki-connect."""
result = self.anki.create_cards(
deck, cards, self.source_language, self.target_language
)
if result.is_err():
logger.error(f"Error creating Anki cards: {result.err_value}")
return result
def export_as_yaml(self, e):
"""Export the flashcards as a YAML file."""
@ -727,6 +872,209 @@ class FlashcardCreator:
allowed_extensions=["csv"],
)
# --- Methods for Handling Failed Anki Card Additions ---
def _build_failed_card_row(
self, page: ft.Page, card_data: Dict[str, Any]
) -> ft.Row:
"""Builds a Flet Row for displaying a failed card with editable fields."""
front_field = ft.TextField(
value=card_data["card"]["front"],
width=300,
multiline=True,
min_lines=1,
max_lines=3,
)
back_field = ft.TextField(
value=card_data["card"]["back"],
width=300,
multiline=True,
min_lines=1,
max_lines=3,
)
error_text = ft.Text(
f"Error: {card_data['error']}", color=ft.colors.ERROR, size=12
)
# Store references to the fields within the row for later access
row = ft.Row(
controls=[
ft.Column([front_field], width=310), # Column to constrain width
ft.Column([back_field], width=310), # Column to constrain width
ft.Column(
[error_text], expand=True, alignment=ft.MainAxisAlignment.CENTER
),
],
alignment=ft.MainAxisAlignment.START,
)
# Attach fields and original data to the row object itself for easy retrieval
row.front_field = front_field
row.back_field = back_field
row.card_data = card_data
return row
def show_failed_cards_dialog(
self,
page: ft.Page,
deck: str,
failed_cards: List[Dict[str, Any]],
initial_success_count: int,
total_attempted: int,
):
"""Displays a dialog with failed Anki cards, allowing editing and retrying."""
failed_card_rows = [
self._build_failed_card_row(page, card_data) for card_data in failed_cards
]
# Use ListView for scrollability if many cards fail
content_list = ft.ListView(controls=failed_card_rows, expand=True, spacing=10)
dialog_title = ft.Text(f"Failed Cards for Deck: {deck}")
status_text = ft.Text(
f"{initial_success_count}/{total_attempted} added initially. {len(failed_cards)} failed."
)
# --- Retry Logic ---
def _retry_failed_cards(e):
cards_to_retry = []
for (
row
) in content_list.controls: # Access controls directly from the ListView
if hasattr(row, "front_field") and hasattr(
row, "back_field"
): # Ensure it's a card row
cards_to_retry.append(
{"front": row.front_field.value, "back": row.back_field.value}
)
if not cards_to_retry:
page.open(
ft.SnackBar(
content=ft.Text("No cards remaining to retry."), open=True
)
)
return
# Disable button during retry
retry_button.disabled = True
page.update()
# Show progress in dialog (optional, could use snackbar again)
# status_text.value = "Retrying..."
# page.update()
try:
retry_result = self.create_anki_cards(deck, cards_to_retry)
if retry_result.is_ok():
retry_successful_count, still_failed_list = retry_result.ok_value
new_total_successful = (
initial_success_count + retry_successful_count
)
# Update the dialog content: keep only the ones that *still* failed
new_failed_rows = []
still_failed_cards_data = {
tuple(fc["card"].items()): fc for fc in still_failed_list
} # For quick lookup
updated_failed_cards_list = [] # Keep track of data for next retry
for row in content_list.controls:
if hasattr(row, "card_data"):
original_card_tuple = tuple(row.card_data["card"].items())
if original_card_tuple in still_failed_cards_data:
# Update error message if it changed (though unlikely for duplicates)
row.card_data["error"] = still_failed_cards_data[
original_card_tuple
]["error"]
# Rebuild or update the error text in the row if needed
# For simplicity, we'll just keep the row
new_failed_rows.append(row)
updated_failed_cards_list.append(row.card_data)
content_list.controls = (
new_failed_rows # Replace controls in ListView
)
status_text.value = f"{new_total_successful}/{total_attempted} added. {len(new_failed_rows)} still failing."
if not new_failed_rows:
# All retried cards were successful! Close dialog.
page.close(dialog)
page.open(
ft.SnackBar(
content=ft.Text(
f"All cards successfully added! ({new_total_successful}/{total_attempted})"
),
open=True,
)
)
else:
# Some still failed, update dialog view
page.open(
ft.SnackBar(
content=ft.Text(
f"Successfully retried {retry_successful_count} cards. {len(new_failed_rows)} still failed."
),
open=True,
)
)
else: # Handle Err from retry attempt
status_text.value = f"Retry Error: {retry_result.err_value}"
page.open(
ft.SnackBar(
content=ft.Text(
f"Error during retry: {retry_result.err_value}"
),
open=True,
)
)
except Exception as ex:
logger.error(
f"Unexpected error during Anki card retry: {ex}", exc_info=True
)
status_text.value = f"Unexpected Retry Error: {ex}"
page.open(
ft.SnackBar(
content=ft.Text(
f"An unexpected error occurred during retry: {ex}"
),
open=True,
)
)
finally:
# Re-enable button
retry_button.disabled = (
False if content_list.controls else True
) # Disable if list is empty
page.update() # Update UI (dialog content, status text, button state)
# --- Dialog Buttons ---
retry_button = ft.TextButton(
"Retry Failed Cards",
on_click=_retry_failed_cards,
disabled=not failed_card_rows,
)
close_button = ft.TextButton("Close", on_click=lambda e: page.close(dialog))
dialog = ft.AlertDialog(
modal=True,
title=dialog_title,
content=ft.Container( # Use Container for better sizing control
content=ft.Column([status_text, content_list], tight=True),
width=max(page.width * 0.8, 700), # Make dialog wider
height=max(page.height * 0.7, 400), # Make dialog taller
padding=10,
),
actions=[retry_button, close_button],
actions_alignment=ft.MainAxisAlignment.END,
)
page.open(dialog)
page.update()
def main(page: ft.Page):
app = FlashcardCreator()

View File

@ -7,5 +7,5 @@ requires-python = ">=3.12"
dependencies = [
"flet[all]>=0.27.6",
"litellm>=1.63.12",
"logging>=0.4.9.6",
"result>=0.17.0",
]

19
uv.lock
View File

@ -574,12 +574,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/01/6f2fb287b93144851bfd442b265a67933bbd677e6dcbfa9951063199cf94/litellm-1.63.12-py3-none-any.whl", hash = "sha256:ae72a9d7099100b4b1172aaa2954bf6d7b205d47ba76beec5cd53f62dd57913e", size = 6954945 },
]
[[package]]
name = "logging"
version = "0.4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/93/4b/979db9e44be09f71e85c9c8cfc42f258adfb7d93ce01deed2788b2948919/logging-0.4.9.6.tar.gz", hash = "sha256:26f6b50773f085042d301085bd1bf5d9f3735704db9f37c1ce6d8b85c38f2417", size = 96029 }
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@ -646,14 +640,14 @@ source = { virtual = "." }
dependencies = [
{ name = "flet", extra = ["all"] },
{ name = "litellm" },
{ name = "logging" },
{ name = "result" },
]
[package.metadata]
requires-dist = [
{ name = "flet", extras = ["all"], specifier = ">=0.27.6" },
{ name = "litellm", specifier = ">=1.63.12" },
{ name = "logging", specifier = ">=0.4.9.6" },
{ name = "result", specifier = ">=0.17.0" },
]
[[package]]
@ -1027,6 +1021,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "result"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/47/2175be65744aa4d8419c27bd3a7a7d65af5bcad7a4dc6a812c00778754f0/result-0.17.0.tar.gz", hash = "sha256:b73da420c0cb1a3bf741dbd41ff96dedafaad6a1b3ef437a9e33e380bb0d91cf", size = 20180 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/90/19110ce9374c3db619e2df0816f2c58e4ddc5cdad5f7284cd81d8b30b7cb/result-0.17.0-py3-none-any.whl", hash = "sha256:49fd668b4951ad15800b8ccefd98b6b94effc789607e19c65064b775570933e8", size = 11689 },
]
[[package]]
name = "rich"
version = "13.9.4"