refactor: provide a proper TUI interface with textual

This commit is contained in:
2026-06-29 16:48:46 +03:00
parent 3ab6b837f2
commit 5667833907
7 changed files with 391 additions and 160 deletions
+1 -1
View File
@@ -8,6 +8,6 @@
!uv.lock
!random_file_picker/
!random_file_picker/__init__.py
!random_file_picker/cli.py
!random_file_picker/core.py
!random_file_picker/openers.py
!random_file_picker/tui.py
+3 -1
View File
@@ -6,6 +6,8 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"requests>=2.32.5",
"textual>=8.2.7",
"typed-argparse>=0.3.1",
]
[build-system]
@@ -13,7 +15,7 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project.scripts]
pick-random-file = "random_file_picker.cli:main"
pick-random-file = "random_file_picker.tui:main"
[tool.uv.extra-build-dependencies]
random-file-picker = ["setuptools"]
-134
View File
@@ -1,134 +0,0 @@
"""
A script to randomly select files from a directory, open them, and allow the user to upload them to a Discord webhook, delete them, or skip to the next file.
You can specify a file size limit, whether to descend into subdirectories, and provide blacklist/whitelist patterns for file selection.
Here's an example usage:
./rand.py -fl 5 -d /path/to/directory --descend --blacklist "*.png" "*.mp4"
"""
import argparse
import os
import pathlib
import random
import sys
import termios
import tty
from random_file_picker.core import collect_files, move_file_to_meme_folder, upload_file
from random_file_picker.openers import open_file_with_geeqie, open_path_in_dolphin, open_path_with_default_app
IMG_EXTENSIONS = [
".jpg",
".jpeg",
".webp",
".png",
".gif",
]
def getch() -> str:
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setcbreak(sys.stdin.fileno())
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return ch
def main():
parser = argparse.ArgumentParser(description="Random File Sender")
parser.add_argument('-fl', '--file-limit', type=int, default=10, help='Maximum file size in MB')
parser.add_argument('-d', '--directory', type=str, default='.', help='Directory to search for files')
parser.add_argument('--descend', action='store_true', help='Whether to descend into subdirectories')
parser.add_argument('--use-geeqie', action='store_true', help='Whether to use Geeqie as the image viewer')
parser._positionals.title = 'Positional arguments'
parser._optionals.title = 'Optional arguments'
# Globs for blacklisting and whitelisting, --blacklist "*.png" "*.mp4"
parser.add_argument('--blacklist', nargs='*', help='List of glob patterns to blacklist files')
parser.add_argument('--whitelist', nargs='*', help='List of glob patterns to whitelist files')
args = parser.parse_args()
file_limit: int = args.file_limit
directory: str = args.directory
descend: bool = args.descend
blacklist_patterns: list[str] = args.blacklist if args.blacklist else []
whitelist_patterns: list[str] = args.whitelist if args.whitelist else []
use_geeqie: bool = args.use_geeqie
print(f"File size limit set to {file_limit} MB")
search_path = pathlib.Path(directory)
if not search_path.exists() or not search_path.is_dir():
print(f"Directory '{directory}' does not exist or is not a directory.")
return
usable_files = collect_files(
search_path=search_path,
search_subdirectories=descend,
file_size_limit=file_limit,
blacklist_patterns=blacklist_patterns,
whitelist_patterns=whitelist_patterns,
)
if not usable_files:
print("No usable files found.")
return
pickFile = True
while pickFile:
file = random.choice(usable_files)
print(f"Random file: {file}")
if use_geeqie and str(file).lower().endswith(tuple(IMG_EXTENSIONS)):
open_file_with_geeqie(file)
else:
open_path_with_default_app(file)
selectionTime = True
while selectionTime:
print("Options: [u]pload, [n]ext, [d]elete, [f]older, [q]uit")
choice = getch().lower()
# Skip escape sequences (arrow keys)
if choice.encode("utf-8") == b'\x1b':
_ = getch() # Skip the next two characters
_ = getch()
continue
#print(choice.encode("utf-8")) # For debugging purposes
if choice == "u":
print("\nUploading...")
upload_file(file)
print(f"\nSuccessfully uploaded {file}")
print("Upload complete.")
#selectionTime = False
elif choice == "d":
print("\nDeleting...")
os.remove(file)
usable_files.remove(file)
selectionTime = False
elif choice == "n":
print("\nNext file...")
selectionTime = False
usable_files.remove(file)
elif choice == "f":
open_path_in_dolphin(file)
elif choice == "q":
print("\nQuitting...")
pickFile = False
break
elif choice == "m":
print("\nMoving to meme folder...")
move_file_to_meme_folder(file)
usable_files.remove(file)
selectionTime = False
else:
print(f"\n'{choice}' is an invalid option. Please choose again.")
if __name__ == "__main__":
main()
+7 -17
View File
@@ -67,7 +67,6 @@ def collect_files(
collected_files.append(file_path)
return collected_files
def upload_file(file_path: pathlib.Path) -> None:
"""
Upload the specified file to a Discord webhook.
@@ -75,14 +74,13 @@ def upload_file(file_path: pathlib.Path) -> None:
:param file_path: The path of the file to be uploaded.
"""
if WEBHOOK_URL is None:
print("Error: RANDOM_FILE_PICKER_WEBHOOK_URL environment variable is not set.")
return
raise ValueError("RANDOM_FILE_PICKER_WEBHOOK_URL environment variable is not set.")
filename = file_path.name
with open(file_path, "rb") as image_file:
with file_path.open("rb") as image_file:
image_data = image_file.read()
files = {"file": (filename, io.BytesIO(image_data))}
response = requests.post(WEBHOOK_URL, files=files, data={})
response = requests.post(WEBHOOK_URL, files=files, data={}, timeout=60*5)
response.raise_for_status() # Raise HTTPError for bad responses
def move_file_to_meme_folder(file_path: pathlib.Path) -> None:
@@ -92,19 +90,16 @@ def move_file_to_meme_folder(file_path: pathlib.Path) -> None:
:param file_path: The path of the file to be moved.
"""
if MEME_FOLDER is None:
print("Error: RANDOM_FILE_PICKER_MEME_FOLDER environment variable is not set.")
return
raise ValueError("RANDOM_FILE_PICKER_MEME_FOLDER environment variable is not set.")
meme_dir_path = pathlib.Path(MEME_FOLDER)
# Make sure the meme folder exists
if not meme_dir_path.exists():
print(f"Error: Meme folder '{meme_dir_path}' does not exist.")
return
raise RuntimeError(f"Meme folder '{meme_dir_path}' does not exist.")
# Make sure the file being moved is not already in the meme folder
if file_path.parent.resolve() == meme_dir_path.resolve():
print("File is already in the meme folder.")
return
raise RuntimeError("File is already in the meme folder.")
# Formulate the destination path and avoid overwriting existing files
base_name = file_path.name
@@ -115,9 +110,4 @@ def move_file_to_meme_folder(file_path: pathlib.Path) -> None:
while destination_path.exists():
destination_path = meme_dir_path / f"{name}_{counter}{ext}"
counter += 1
ok = file_path.rename(destination_path)
if not ok:
print(f"Error moving {file_path} to {destination_path}")
else:
print(f"Moved {file_path} to {destination_path}")
_ = file_path.rename(destination_path)
+5 -6
View File
@@ -3,13 +3,14 @@ import pathlib
import subprocess
def open_path_with_default_app(file_path: pathlib.Path):
def open_file_with_default_app(file_path: pathlib.Path) -> bool:
"""
Open the specified file path with the default application associated with its file type.
:param file_path: The path of the file to be opened.
"""
subprocess.run(["/usr/bin/xdg-open", str(file_path)])
process = subprocess.Popen(["/usr/bin/xdg-open", str(file_path)]) # noqa: S603
return process.wait() == 0 # xdg-open should instantly return, so we can check if it was successful
def open_file_with_geeqie(file_path: pathlib.Path):
"""
@@ -17,21 +18,19 @@ def open_file_with_geeqie(file_path: pathlib.Path):
:param file_path: The path of the file to be opened.
"""
print("Running subprocess to open file with Geeqie:", file_path)
my_env = os.environ.copy()
my_env["GQ_DISABLE_CLUTTER"] = "y"
subprocess.Popen(
_ = subprocess.Popen(
[
"/usr/bin/geeqie",
# "-t",
# "--without-tools",
"--file",
str(file_path),
],
env=my_env,
)
def open_path_in_dolphin(file_path: pathlib.Path):
def open_file_in_dolphin(file_path: pathlib.Path):
"""
Open the specified file path in the Dolphin file manager and select the file.
+236
View File
@@ -0,0 +1,236 @@
"""
A script to randomly select files from a directory, open them, and allow the user to upload them to a Discord webhook, delete them, or skip to the next file.
You can specify a file size limit, whether to descend into subdirectories, and provide blacklist/whitelist patterns for file selection.
Here's an example usage:
./rand.py -s 5 -d /path/to/directory --descend --blacklist "*.png" "*.mp4"
"""
import pathlib
import random
import typed_argparse as tap
from textual.app import App, ComposeResult
from textual.binding import BindingType
from textual.widgets import Footer, Header, Label
from random_file_picker.core import collect_files, move_file_to_meme_folder, upload_file
from random_file_picker.openers import open_file_in_dolphin, open_file_with_default_app, open_file_with_geeqie
IMG_EXTENSIONS = [
".jpg", ".jpeg", ".webp", ".png", ".gif",
]
class Args(tap.TypedArgs):
file_size: int = tap.arg("-s", default=10, help="Maximum file size in MB")
directory: str = tap.arg("-d", default=".", help="Directory to search for files")
descend: bool = tap.arg("--descend", default=False, help="Whether to descend into subdirectories")
use_geeqie: bool = tap.arg("--use-geeqie", default=False, help="Whether to use Geeqie as the image viewer")
blacklist: list[str] | None = tap.arg("--blacklist", nargs="*", help="List of glob patterns to blacklist files")
whitelist: list[str] | None = tap.arg("--whitelist", nargs="*", help="List of glob patterns to whitelist files")
class MenuApp(App[None]):
# This defines the keys shown in the footer
BINDINGS: list[BindingType] = [
("left", "prev_file", "Previous File"),
("right", "next_file", "Next File"),
("r", "reopen_file", "Reopen"),
("u", "upload", "Upload"),
("d", "delete", "Delete"),
("m", "move_to_meme", "Move Meme"),
("f", "open_folder", "Open Folder"),
("q", "quit", "Quit"),
]
def __init__(
self,
directory: str = ".",
file_limit: int = 10,
recurse: bool = False,
blacklist: list[str] | None = None,
whitelist: list[str] | None = None,
use_geeqie: bool = False,
):
super().__init__()
self.directory: pathlib.Path = pathlib.Path(directory)
self.file_limit: int = file_limit
self.recurse: bool = recurse
self.blacklist_patterns: list[str] = blacklist or []
self.whitelist_patterns: list[str] = whitelist or []
self.use_geeqie: bool = use_geeqie
self.usable_files: list[pathlib.Path] = []
self.current_index: int = 0
self.current_file: pathlib.Path | None = None
def compose(self) -> ComposeResult:
yield Header()
yield Label("Loading files...", id="status")
yield Label("", id="current-file")
yield Footer()
def on_mount(self) -> None:
self.gather_files()
if self.usable_files:
self.current_index = 0
self.display_current_file()
def display_current_file(self) -> None:
"""Display the current file based on current_index."""
if not self.usable_files or self.current_index >= len(self.usable_files):
self.query_one("#status", Label).update("❌ No files available")
self.current_file = None
return
self.current_file = self.usable_files[self.current_index]
file_count_str = f"({self.current_index + 1}/{len(self.usable_files)})"
self.query_one("#current-file", Label).update(f"📄 {self.current_file} {file_count_str}")
self.open_file()
def gather_files(self) -> None:
"""Collect files from the directory based on filters."""
self.usable_files = []
if not self.directory.exists() or not self.directory.is_dir():
self.query_one("#status", Label).update(f"❌ Directory '{self.directory}' not found")
return
self.usable_files = collect_files(
search_path=self.directory,
search_subdirectories=self.recurse,
file_size_limit=self.file_limit,
blacklist_patterns=self.blacklist_patterns,
whitelist_patterns=self.whitelist_patterns,
)
if not self.usable_files:
self.query_one("#status", Label).update("❌ No usable files found")
else:
self.query_one("#status", Label).update(f"✅ Found {len(self.usable_files)} files")
random.shuffle(self.usable_files)
def open_file(self) -> None:
"""Open the current file - display images in terminal, play others externally."""
if not self.current_file:
return
self.query_one("#status", Label).update(f"📝 Open {self.current_file}")
try:
if self.use_geeqie and str(self.current_file).lower().endswith(tuple(IMG_EXTENSIONS)):
open_file_with_geeqie(self.current_file)
else:
ok = open_file_with_default_app(self.current_file)
if not ok:
self.query_one("#status", Label).update("❌ Failed to open file with default app")
except Exception as e:
self.query_one("#status", Label).update(f"❌ Failed to open file: {str(e)}")
def send_file(self) -> None:
"""Upload the current file to Discord webhook."""
if not self.current_file:
return
try:
self.query_one("#status", Label).update(f"⏳ Uploading {self.current_file.name} to webhook...")
upload_file(self.current_file)
self.query_one("#status", Label).update(f"✅ Uploaded {self.current_file.name} to webhook")
except Exception as e:
self.query_one("#status", Label).update(f"❌ Upload failed: {str(e)}")
def delete_file(self) -> None:
"""Delete the current file."""
if not self.current_file or self.current_index >= len(self.usable_files):
return
try:
self.current_file.unlink()
_ = self.usable_files.pop(self.current_index)
self.query_one("#status", Label).update("✅ Deleted file")
# Adjust index if we deleted the last file
if self.current_index >= len(self.usable_files) and self.usable_files:
self.current_index = len(self.usable_files) - 1
if self.usable_files:
self.display_current_file()
else:
self.query_one("#status", Label).update("❌ No more files")
except Exception as e:
self.query_one("#status", Label).update(f"❌ Delete failed: {str(e)}")
def move_to_meme_folder(self) -> None:
"""Move the current file to the meme folder."""
if not self.current_file or self.current_index >= len(self.usable_files):
return
try:
move_file_to_meme_folder(self.current_file)
_ = self.usable_files.pop(self.current_index)
self.query_one("#status", Label).update("✅ Moved to meme folder")
# Adjust index if we removed the last file
if self.current_index >= len(self.usable_files) and self.usable_files:
self.current_index = len(self.usable_files) - 1
if self.usable_files:
self.display_current_file()
else:
self.query_one("#status", Label).update("❌ No more files")
except Exception as e:
self.query_one("#status", Label).update(f"❌ Move failed: {str(e)}")
def open_in_folder(self) -> None:
"""Open the file's folder in file manager."""
if not self.current_file:
return
open_file_in_dolphin(self.current_file)
self.query_one("#status", Label).update("📂 Opened file in folder")
def action_upload(self) -> None:
self.send_file()
def action_delete(self) -> None:
self.delete_file()
def action_move_to_meme(self) -> None:
self.move_to_meme_folder()
def action_open_folder(self) -> None:
self.open_in_folder()
def action_next_file(self) -> None:
if not self.usable_files:
return
self.current_index = (self.current_index + 1) % len(self.usable_files)
self.display_current_file()
def action_prev_file(self) -> None:
if not self.usable_files:
return
self.current_index = (self.current_index - 1) % len(self.usable_files)
self.display_current_file()
def action_reopen_file(self) -> None:
if not self.current_file:
return
self.open_file()
self.query_one("#status", Label).update("🔄 Reopened file")
def main_with_tap(args: Args) -> None:
app = MenuApp(
directory=args.directory,
file_limit=args.file_size,
recurse=args.descend,
blacklist=args.blacklist or [],
whitelist=args.whitelist or [],
use_geeqie=args.use_geeqie,
)
app.run()
def main() -> None:
tap.Parser(Args).bind(main_with_tap).run()
if __name__ == "__main__":
main()
Generated
+139 -1
View File
@@ -2,6 +2,10 @@ version = 1
revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P1W"
[[package]]
name = "certifi"
version = "2026.2.25"
@@ -77,16 +81,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "random-file-picker"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "requests" },
{ name = "textual" },
{ name = "typed-argparse" },
]
[package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.32.5" }]
requires-dist = [
{ name = "requests", specifier = ">=2.32.5" },
{ name = "textual", specifier = ">=8.2.7" },
{ name = "typed-argparse", specifier = ">=0.3.1" },
]
[[package]]
name = "requests"
@@ -103,6 +181,66 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "textual"
version = "8.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" },
]
[[package]]
name = "typed-argparse"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/74/2608ef98de41cd82743be47185048db93b4ff1bc59714666145de4376a1a/typed-argparse-0.3.1.tar.gz", hash = "sha256:3aac61caa50206e080d09a00c3fe552bc4e642739beaef89f5f8c1131b5d5afe", size = 18394, upload-time = "2023-09-25T20:40:20.337Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/86/2217b32ee0f421ebf57df7ff898638eedfe2288aa6710dba2bca0607b689/typed_argparse-0.3.1-py3-none-any.whl", hash = "sha256:1fbbc3c6adde19aa04edafebd8a7efccab4ddd403d03ec68172cc20b237bbaa7", size = 19686, upload-time = "2023-09-25T20:40:18.691Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"