diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba8453a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +.claude/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..86bc1e8 --- /dev/null +++ b/conftest.py @@ -0,0 +1,61 @@ +"""Pytest configuration and fixtures for mcquack tests.""" + +import os +import stat +from pathlib import Path + +import pytest + + +@pytest.fixture +def mock_dirs(tmp_path, monkeypatch): + """Set up temporary directories for LAUNCH_AGENTS_DIR and LOGS_DIR. + + Returns a dict with the paths for test assertions. + """ + import mcquack + + launch_agents_dir = tmp_path / "LaunchAgents" + logs_dir = tmp_path / "Logs" + launch_agents_dir.mkdir() + logs_dir.mkdir() + + monkeypatch.setattr(mcquack, "LAUNCH_AGENTS_DIR", launch_agents_dir) + monkeypatch.setattr(mcquack, "LOGS_DIR", logs_dir) + + return { + "launch_agents_dir": launch_agents_dir, + "logs_dir": logs_dir, + "tmp_path": tmp_path, + } + + +@pytest.fixture +def mock_script(tmp_path): + """Create a mock executable script for testing. + + Returns the path to the script. + """ + script = tmp_path / "test_script.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return script + + +@pytest.fixture +def mock_launchctl(monkeypatch): + """Mock subprocess.run to avoid actual launchctl calls. + + Returns a list that captures all subprocess.run calls for assertions. + """ + import subprocess + + calls = [] + + def mock_run(args, **kwargs): + calls.append({"args": args, "kwargs": kwargs}) + result = subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="") + return result + + monkeypatch.setattr("subprocess.run", mock_run) + return calls diff --git a/mcquack.py b/mcquack.py index dea3e39..b3fb493 100755 --- a/mcquack.py +++ b/mcquack.py @@ -47,8 +47,8 @@ def create_plist_dict(script_path: Path, args: list[str]) -> dict: } -@app.command() -def list() -> None: +@app.command("list") +def list_agents() -> None: """List all mcquack-managed LaunchAgents.""" if not LAUNCH_AGENTS_DIR.exists(): typer.echo("No LaunchAgents directory found.") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c4103de --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "mcquack" +version = "0.1.0" +description = "A simple macOS LaunchAgent manager for executable scripts" +readme = "README.md" +authors = [ + { name = "Erich Blume", email = "blume.erich@gmail.com" } +] +requires-python = ">=3.14" +dependencies = ["typer>=0.9.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] +only-include = ["mcquack.py"] + +[tool.hatch.build.targets.wheel.sources] +"mcquack.py" = "mcquack.py" + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/test_mcquack.py b/test_mcquack.py new file mode 100644 index 0000000..8237bc4 --- /dev/null +++ b/test_mcquack.py @@ -0,0 +1,279 @@ +"""Tests for mcquack.""" + +import plistlib +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +import mcquack +from mcquack import app + +runner = CliRunner() + + +def get_plist_path(mock_dirs, script_name="test_script"): + """Helper to get the plist path for a script.""" + return mock_dirs["launch_agents_dir"] / f"{mcquack.PLIST_PREFIX}.{script_name}.plist" + + +def read_plist_args(plist_path): + """Read ProgramArguments from a plist file.""" + with open(plist_path, "rb") as f: + plist = plistlib.load(f) + return plist.get("ProgramArguments", []) + + +class TestList: + def test_list_empty(self, mock_dirs): + """Test list command with no agents.""" + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "No mcquack-managed LaunchAgents found" in result.stdout + + def test_list_shows_agents(self, mock_dirs, mock_script): + """Test list command shows existing agents.""" + # Create a plist file manually + plist_path = get_plist_path(mock_dirs) + plist_data = { + "Label": f"{mcquack.PLIST_PREFIX}.test_script", + "ProgramArguments": [str(mock_script)], + } + with open(plist_path, "wb") as f: + plistlib.dump(plist_data, f) + + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "test_script" in result.stdout + + +class TestCreate: + @pytest.mark.parametrize("script_args,expected_args", [ + ([], []), + (["--verbose"], ["--verbose"]), + (["--config", "/path/to/config.json"], ["--config", "/path/to/config.json"]), + (["arg1", "arg2", "arg3"], ["arg1", "arg2", "arg3"]), + (["--flag", "value with spaces"], ["--flag", "value with spaces"]), + ]) + def test_create_with_arguments(self, mock_dirs, mock_script, mock_launchctl, script_args, expected_args): + """Test creating agents with various argument configurations.""" + cmd = ["create", str(mock_script)] + if script_args: + cmd += ["--"] + script_args + result = runner.invoke(app, cmd) + assert result.exit_code == 0 + assert "Created:" in result.stdout + assert "Loaded:" in result.stdout + + # Verify plist was created with correct arguments + plist_path = get_plist_path(mock_dirs) + assert plist_path.exists() + + program_args = read_plist_args(plist_path) + assert program_args[0] == str(mock_script.resolve()) + assert program_args[1:] == expected_args + + def test_create_nonexistent_script(self, mock_dirs): + """Test creating agent for nonexistent script.""" + result = runner.invoke(app, ["create", "/nonexistent/script.sh"]) + assert result.exit_code == 1 + assert "does not exist" in result.output + + def test_create_non_executable(self, mock_dirs, tmp_path): + """Test creating agent for non-executable script.""" + script = tmp_path / "not_executable.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + # Don't make it executable + + result = runner.invoke(app, ["create", str(script)]) + assert result.exit_code == 1 + assert "not executable" in result.output + + def test_create_already_exists(self, mock_dirs, mock_script, mock_launchctl): + """Test creating agent that already exists.""" + # Create first + runner.invoke(app, ["create", str(mock_script)]) + + # Try to create again + result = runner.invoke(app, ["create", str(mock_script)]) + assert result.exit_code == 1 + assert "already exists" in result.output + + +class TestEdit: + def test_edit_add_arguments(self, mock_dirs, mock_script, mock_launchctl): + """Test adding arguments to an existing agent.""" + # Create without arguments + runner.invoke(app, ["create", str(mock_script)]) + + # Edit to add arguments + result = runner.invoke(app, ["edit", str(mock_script), "--", "--verbose", "--config", "test.json"]) + assert result.exit_code == 0 + assert "Updated:" in result.stdout + assert "Reloaded:" in result.stdout + + # Verify arguments were added + program_args = read_plist_args(get_plist_path(mock_dirs)) + assert program_args[1:] == ["--verbose", "--config", "test.json"] + + def test_edit_remove_arguments(self, mock_dirs, mock_script, mock_launchctl): + """Test removing arguments from an existing agent.""" + # Create with arguments + runner.invoke(app, ["create", str(mock_script), "--", "--old-arg", "old-value"]) + + # Edit with no arguments (clears them) + result = runner.invoke(app, ["edit", str(mock_script)]) + assert result.exit_code == 0 + + # Verify arguments were removed + program_args = read_plist_args(get_plist_path(mock_dirs)) + assert len(program_args) == 1 # Only the script path remains + + def test_edit_replace_arguments(self, mock_dirs, mock_script, mock_launchctl): + """Test replacing arguments on an existing agent.""" + # Create with initial arguments + runner.invoke(app, ["create", str(mock_script), "--", "--old"]) + + # Edit to replace with new arguments + result = runner.invoke(app, ["edit", str(mock_script), "--", "--new", "value"]) + assert result.exit_code == 0 + + # Verify arguments were replaced + program_args = read_plist_args(get_plist_path(mock_dirs)) + assert program_args[1:] == ["--new", "value"] + + def test_edit_nonexistent(self, mock_dirs, mock_script): + """Test editing nonexistent agent.""" + result = runner.invoke(app, ["edit", str(mock_script), "--", "--arg"]) + assert result.exit_code == 1 + assert "does not exist" in result.output + + @pytest.mark.parametrize("new_args", [ + ["--single"], + ["--multi", "arg1", "arg2"], + [], + ]) + def test_edit_various_arguments(self, mock_dirs, mock_script, mock_launchctl, new_args): + """Test editing with various argument configurations.""" + runner.invoke(app, ["create", str(mock_script), "--", "--initial"]) + + cmd = ["edit", str(mock_script)] + if new_args: + cmd += ["--"] + new_args + result = runner.invoke(app, cmd) + assert result.exit_code == 0 + + program_args = read_plist_args(get_plist_path(mock_dirs)) + assert program_args[1:] == new_args + + +class TestShow: + @pytest.mark.parametrize("script_args,expected_display", [ + ([], "(none)"), + (["--verbose"], "--verbose"), + (["--config", "test.json"], "--config test.json"), + ]) + def test_show_with_arguments(self, mock_dirs, mock_script, mock_launchctl, script_args, expected_display): + """Test showing agent details with various arguments.""" + cmd = ["create", str(mock_script)] + if script_args: + cmd += ["--"] + script_args + runner.invoke(app, cmd) + + result = runner.invoke(app, ["show", str(mock_script)]) + assert result.exit_code == 0 + assert "Label:" in result.stdout + assert "test_script" in result.stdout + assert f"Arguments: {expected_display}" in result.stdout + + def test_show_nonexistent(self, mock_dirs, mock_script): + """Test showing nonexistent agent.""" + result = runner.invoke(app, ["show", str(mock_script)]) + assert result.exit_code == 1 + assert "does not exist" in result.output + + +class TestDelete: + def test_delete_agent(self, mock_dirs, mock_script, mock_launchctl): + """Test deleting an agent.""" + runner.invoke(app, ["create", str(mock_script)]) + + result = runner.invoke(app, ["delete", str(mock_script)]) + assert result.exit_code == 0 + assert "Deleted:" in result.stdout + + # Verify plist was removed + plist_path = get_plist_path(mock_dirs) + assert not plist_path.exists() + + def test_delete_nonexistent(self, mock_dirs, mock_script): + """Test deleting nonexistent agent.""" + result = runner.invoke(app, ["delete", str(mock_script)]) + assert result.exit_code == 1 + assert "does not exist" in result.output + + +class TestPlistFormat: + """Tests verifying plist file format independently of plistlib.""" + + def test_plist_is_valid_xml(self, mock_dirs, mock_script, mock_launchctl): + """Verify created plist is valid XML (not relying on plistlib).""" + runner.invoke(app, ["create", str(mock_script), "--", "--test-arg", "value"]) + + plist_path = get_plist_path(mock_dirs) + + # Parse as raw XML + tree = ET.parse(plist_path) + root = tree.getroot() + + assert root.tag == "plist" + assert root.attrib.get("version") == "1.0" + + def test_plist_contains_expected_keys(self, mock_dirs, mock_script, mock_launchctl): + """Verify plist contains required LaunchAgent keys via XML parsing.""" + runner.invoke(app, ["create", str(mock_script), "--", "--arg1", "val1"]) + + plist_path = get_plist_path(mock_dirs) + tree = ET.parse(plist_path) + root = tree.getroot() + + # Find the dict element containing key-value pairs + dict_elem = root.find("dict") + assert dict_elem is not None + + # Extract keys from the plist + keys = [elem.text for elem in dict_elem.findall("key")] + + assert "Label" in keys + assert "ProgramArguments" in keys + assert "RunAtLoad" in keys + assert "KeepAlive" in keys + assert "StandardOutPath" in keys + assert "StandardErrorPath" in keys + + def test_plist_program_arguments_structure(self, mock_dirs, mock_script, mock_launchctl): + """Verify ProgramArguments array structure via XML parsing.""" + runner.invoke(app, ["create", str(mock_script), "--", "--flag", "value"]) + + plist_path = get_plist_path(mock_dirs) + tree = ET.parse(plist_path) + root = tree.getroot() + dict_elem = root.find("dict") + + # Find ProgramArguments key and its following array + children = list(dict_elem) + for i, child in enumerate(children): + if child.tag == "key" and child.text == "ProgramArguments": + array_elem = children[i + 1] + assert array_elem.tag == "array" + + # Extract string values from array + args = [s.text for s in array_elem.findall("string")] + assert len(args) == 3 # script path + 2 args + assert args[0] == str(mock_script.resolve()) + assert args[1] == "--flag" + assert args[2] == "value" + break + else: + pytest.fail("ProgramArguments key not found") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b117292 --- /dev/null +++ b/uv.lock @@ -0,0 +1,162 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[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]] +name = "mcquack" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "typer", specifier = ">=0.9.0" }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + +[[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 = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[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 = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[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" }, +]