diff --git a/.gitignore b/.gitignore index c91b0fa..c5b2953 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 9fffa22..8f3ee0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/random_file_picker/cli.py b/random_file_picker/cli.py deleted file mode 100755 index c4fbc23..0000000 --- a/random_file_picker/cli.py +++ /dev/null @@ -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() diff --git a/random_file_picker/core.py b/random_file_picker/core.py index d1243ca..b74b7e3 100644 --- a/random_file_picker/core.py +++ b/random_file_picker/core.py @@ -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) diff --git a/random_file_picker/openers.py b/random_file_picker/openers.py index 40d9228..f5e59fe 100644 --- a/random_file_picker/openers.py +++ b/random_file_picker/openers.py @@ -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. diff --git a/random_file_picker/tui.py b/random_file_picker/tui.py new file mode 100644 index 0000000..8221ce4 --- /dev/null +++ b/random_file_picker/tui.py @@ -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() diff --git a/uv.lock b/uv.lock index 0e80bf0..e9b099f 100644 --- a/uv.lock +++ b/uv.lock @@ -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"