diff --git a/Makefile b/Makefile index 7c9746b..72e71d4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup run build smoke clean +.PHONY: setup run build smoke clean lint test setup: uv venv @@ -7,6 +7,17 @@ setup: run: uv run python scripts/planka_cli.py status +lint: + uv run ruff check scripts/ tests/ + uv run ruff format --check scripts/ tests/ + +lint-fix: + uv run ruff check --fix scripts/ tests/ + uv run ruff format scripts/ tests/ + +test: + uv run pytest tests/ -v + build: uv run pyinstaller planka-cli.spec --noconfirm @@ -21,4 +32,4 @@ smoke: build ./dist/planka-cli --help clean: - rm -rf dist build __pycache__ scripts/__pycache__ + rm -rf dist build __pycache__ scripts/__pycache__ tests/__pycache__ .pytest_cache diff --git a/pyproject.toml b/pyproject.toml index e0c3b49..6c189a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,21 @@ planka-cli = "scripts.planka_cli:app" [dependency-groups] dev = [ "pyinstaller", + "ruff", + "pytest", ] +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = ["E501"] # Line too long - handled by formatter + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] + [tool.setuptools] packages = ["scripts"] diff --git a/scripts/planka_cli.py b/scripts/planka_cli.py index 000bd38..2e31ce5 100644 --- a/scripts/planka_cli.py +++ b/scripts/planka_cli.py @@ -13,6 +13,7 @@ from rich.console import Console from rich.table import Table from typer.core import TyperGroup + def get_binary_dir() -> Path: argv0 = sys.argv[0] path = Path(argv0) @@ -30,6 +31,7 @@ ENV_PATH = get_binary_dir() / ".env" # Load environment variables from the .env next to the binary/script. load_dotenv(dotenv_path=ENV_PATH) + class HelpOnUnknownCommandGroup(TyperGroup): def get_command(self, ctx: click.Context, cmd_name: str): command = super().get_command(ctx, cmd_name) @@ -52,12 +54,14 @@ app.add_typer(lists_app, name="lists") app.add_typer(cards_app, name="cards") app.add_typer(notifications_app, name="notifications") + @app.callback(invoke_without_command=True) def main(ctx: typer.Context): """Planka CLI.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) + def get_env_config() -> tuple[Optional[str], Optional[str], Optional[str]]: return ( os.getenv("PLANKA_URL"), @@ -65,6 +69,7 @@ def get_env_config() -> tuple[Optional[str], Optional[str], Optional[str]]: os.getenv("PLANKA_PASSWORD"), ) + def parse_iso_datetime(value: Optional[str]) -> Optional[datetime]: if value is None: return None @@ -78,6 +83,7 @@ def parse_iso_datetime(value: Optional[str]) -> Optional[datetime]: "Invalid datetime. Use ISO-8601 like 2025-01-31 or 2025-01-31T10:30:00Z." ) from exc + def parse_position(value: Optional[str]) -> Optional[Union[str, int]]: if value is None: return None @@ -89,6 +95,7 @@ def parse_position(value: Optional[str]) -> Optional[Union[str, int]]: except ValueError as exc: raise typer.BadParameter("Position must be 'top', 'bottom', or an integer.") from exc + def find_list(planka: Planka, list_id: str): for project in planka.projects: for board in project.boards: @@ -97,6 +104,17 @@ def find_list(planka: Planka, list_id: str): return list_item return None + +def find_list_with_board(planka: Planka, list_id: str): + """Find a list and return both the list and its parent board.""" + for project in planka.projects: + for board in project.boards: + for list_item in board.lists: + if list_item.id == list_id: + return list_item, board + return None, None + + def get_card_by_id(planka: Planka, card_id: str) -> Optional[Card]: try: card_data = planka.endpoints.getCard(card_id)["item"] @@ -104,6 +122,7 @@ def get_card_by_id(planka: Planka, card_id: str) -> Optional[Card]: return None return Card(card_data, planka) + def make_table(title: str) -> Table: return Table( title=title, @@ -114,6 +133,7 @@ def make_table(title: str) -> Table: box=None, ) + def render_notifications(title: str, notifications: list) -> None: if not notifications: console.print("No notifications found.") @@ -138,6 +158,7 @@ def render_notifications(title: str, notifications: list) -> None: console.print(table) + def get_planka() -> Planka: planka_url, planka_username, planka_password = get_env_config() if not planka_url or not planka_username or not planka_password: @@ -148,7 +169,7 @@ def get_planka() -> Planka: f"to write {ENV_PATH}." ) sys.exit(1) - + try: planka = Planka(planka_url) planka.login(username=planka_username, password=planka_password) @@ -157,6 +178,7 @@ def get_planka() -> Planka: console.print(f"[bold red]Connection Error:[/bold red] {e}") sys.exit(1) + @app.command() def login( url: str = typer.Option(..., "--url", "-u", help="Planka base URL"), @@ -180,6 +202,7 @@ def login( console.print(f"[green]Saved credentials to[/green] {ENV_PATH}") + @app.command() def logout(): """Delete the stored .env file next to the binary.""" @@ -195,19 +218,23 @@ def logout(): console.print("[green]Logged out.[/green] Removed stored credentials.") + @app.command() def status(): """Check connection and print current user info.""" planka = get_planka() try: user = planka.me - console.print(f"[green]Connected![/green] Logged in as: [bold]{user.username}[/bold] (ID: {user.id})") + console.print( + f"[green]Connected![/green] Logged in as: [bold]{user.username}[/bold] (ID: {user.id})" + ) if user.name: console.print(f"Name: {user.name}") console.print(f"Email: {user.email}") except Exception as e: console.print(f"[bold red]Error fetching status:[/bold red] {e}") + @projects_app.command("list") def list_projects(): """List all projects.""" @@ -230,8 +257,11 @@ def list_projects(): except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + @boards_app.command("list") -def list_boards(project_id: Optional[str] = typer.Argument(None, help="Project ID to filter by")): +def list_boards( + project_id: Optional[str] = typer.Argument(None, help="Project ID to filter by"), +): """List boards. Optionally filter by Project ID.""" planka = get_planka() try: @@ -255,19 +285,25 @@ def list_boards(project_id: Optional[str] = typer.Argument(None, help="Project I console.print("No boards found.") return + planka_url, _, _ = get_env_config() table = make_table(title) table.add_column("ID", justify="right", style="cyan", no_wrap=True) table.add_column("Name", style="magenta") table.add_column("Project ID", justify="right") + table.add_column("URL", style="magenta") for b in boards_list: project_id_value = b.schema.get("projectId") - table.add_row(str(b.id), b.name, str(project_id_value)) + board_url = None + if planka_url and b.id: + board_url = f"{planka_url.rstrip('/')}/boards/{b.id}" + table.add_row(str(b.id), b.name, str(project_id_value), str(board_url or "-")) console.print(table) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + @lists_app.command("list") def list_lists(board_id: str): """List all lists in a board.""" @@ -283,7 +319,7 @@ def list_lists(board_id: str): break if target_board: break - + if not target_board: console.print(f"[red]Board {board_id} not found.[/red]") return @@ -291,40 +327,52 @@ def list_lists(board_id: str): table = make_table(f"Lists in Board: {target_board.name}") table.add_column("ID", justify="right", style="cyan", no_wrap=True) table.add_column("Name", style="magenta") + table.add_column("Board ID", justify="right") table.add_column("Position", justify="right") - for l in target_board.lists: - table.add_row(str(l.id), l.name, str(l.position)) + for list_item in target_board.lists: + table.add_row( + str(list_item.id), list_item.name, str(target_board.id), str(list_item.position) + ) console.print(table) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + @cards_app.command("list") def list_cards(list_id: str): """List all cards in a list.""" planka = get_planka() try: - target_list = find_list(planka, list_id) - + target_list, target_board = find_list_with_board(planka, list_id) + if not target_list: console.print(f"[red]List {list_id} not found.[/red]") return + planka_url, _, _ = get_env_config() + board_id = target_board.id if target_board else None table = make_table(f"Cards in List: {target_list.name}") table.add_column("ID", justify="right", style="cyan", no_wrap=True) table.add_column("Name", style="magenta") + table.add_column("List ID", justify="right") table.add_column("Position", justify="right") + table.add_column("URL", style="magenta") for c in target_list.cards: - table.add_row(str(c.id), c.name, str(c.position)) + card_url = None + if planka_url and board_id and c.id: + card_url = f"{planka_url.rstrip('/')}/boards/{board_id}/cards/{c.id}" + table.add_row(str(c.id), c.name, str(list_id), str(c.position), str(card_url or "-")) console.print(table) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + @cards_app.command("show") def show_card(card_id: str): """Show details for a card.""" @@ -360,7 +408,14 @@ def show_card(card_id: str): def extract_attachment_url(attachment: object, base_url: Optional[str]) -> Optional[str]: data = safe_attr(attachment, "data") if isinstance(data, dict): - for key in ("url", "downloadUrl", "download_url", "link", "href", "path"): + for key in ( + "url", + "downloadUrl", + "download_url", + "link", + "href", + "path", + ): value = data.get(key) if isinstance(value, str) and value.strip(): return normalize_url(base_url, value) @@ -394,11 +449,16 @@ def show_card(card_id: str): except Exception: schema = {} list_id_value = schema.get("listId") + board_id_value = schema.get("boardId") list_name_value = None list_obj = safe_attr(card, "list") if list_obj is not None: list_id_value = safe_attr(list_obj, "id") or list_id_value list_name_value = safe_attr(list_obj, "name") + if not board_id_value: + list_schema = safe_attr(list_obj, "schema") + if isinstance(list_schema, dict): + board_id_value = list_schema.get("boardId") list_display = "-" if list_name_value and list_id_value: @@ -432,9 +492,16 @@ def show_card(card_id: str): if comments_count is None and comments_error is None: comments_count = len(comments) + planka_url, _, _ = get_env_config() + card_url = None + if planka_url and board_id_value and card.id: + card_url = f"{planka_url.rstrip('/')}/boards/{board_id_value}/cards/{card.id}" + add_row("ID", card.id) + add_row("URL", card_url) add_row("Name", card.name) add_row("Description", safe_attr(card, "description") or schema.get("description")) + add_row("Board ID", board_id_value) add_row("List", list_display) add_row("Position", safe_attr(card, "position") or schema.get("position")) add_row("Type", safe_attr(card, "type") or schema.get("type")) @@ -453,7 +520,6 @@ def show_card(card_id: str): console.print(table) - planka_url, _, _ = get_env_config() if attachments and attachments_error is None: attachments_table = make_table("Attachments") attachments_table.add_column("ID", justify="right", style="cyan", no_wrap=True) @@ -502,12 +568,15 @@ def show_card(card_id: str): except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + @cards_app.command("create") def create_card( list_id: str = typer.Argument(..., help="List ID to create the card in"), name: str = typer.Argument(..., help="Card name/title"), description: Optional[str] = typer.Option(None, "--description", "-d", help="Card description"), - position: str = typer.Option("bottom", "--position", "-p", help="Position: top, bottom, or integer"), + position: str = typer.Option( + "bottom", "--position", "-p", help="Position: top, bottom, or integer" + ), card_type: str = typer.Option("project", "--type", "-t", help="Card type (project, story)"), due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date (ISO-8601)"), due_completed: bool = typer.Option(False, "--due-completed", help="Mark due date as completed"), @@ -539,15 +608,24 @@ def create_card( except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + @cards_app.command("update") def update_card( card_id: str = typer.Argument(..., help="Card ID to update"), name: Optional[str] = typer.Option(None, "--name", help="New card name"), - description: Optional[str] = typer.Option(None, "--description", "-d", help="New card description"), - clear_description: bool = typer.Option(False, "--clear-description", help="Clear the description"), - position: Optional[str] = typer.Option(None, "--position", "-p", help="Position: top, bottom, or integer"), + description: Optional[str] = typer.Option( + None, "--description", "-d", help="New card description" + ), + clear_description: bool = typer.Option( + False, "--clear-description", help="Clear the description" + ), + position: Optional[str] = typer.Option( + None, "--position", "-p", help="Position: top, bottom, or integer" + ), list_id: Optional[str] = typer.Option(None, "--list-id", help="Move to a new list"), - card_type: Optional[str] = typer.Option(None, "--type", "-t", help="Card type (project, story)"), + card_type: Optional[str] = typer.Option( + None, "--type", "-t", help="Card type (project, story)" + ), due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date (ISO-8601)"), clear_due_date: bool = typer.Option(False, "--clear-due-date", help="Clear the due date"), due_completed: Optional[bool] = typer.Option( @@ -558,7 +636,9 @@ def update_card( ): """Update an existing card.""" if description is not None and clear_description: - console.print("[bold red]Error:[/bold red] Use either --description or --clear-description.") + console.print( + "[bold red]Error:[/bold red] Use either --description or --clear-description." + ) raise typer.Exit(1) if due_date is not None and clear_due_date: console.print("[bold red]Error:[/bold red] Use either --due-date or --clear-due-date.") @@ -606,6 +686,7 @@ def update_card( except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + @cards_app.command("delete") def delete_card( card_id: str = typer.Argument(..., help="Card ID to delete"), @@ -630,6 +711,7 @@ def delete_card( except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + @notifications_app.command("all") def all_notifications(): """List all notifications.""" @@ -639,6 +721,7 @@ def all_notifications(): except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + @notifications_app.command("unread") def unread_notifications(): """List unread notifications.""" @@ -648,5 +731,6 @@ def unread_notifications(): except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") + if __name__ == "__main__": app() diff --git a/scripts/pyi_rth_plankapy.py b/scripts/pyi_rth_plankapy.py index bc79eb2..6297ba1 100644 --- a/scripts/pyi_rth_plankapy.py +++ b/scripts/pyi_rth_plankapy.py @@ -7,18 +7,14 @@ class _DummyPlanka: """Stub for plankapy.v1.Planka to satisfy root __init__.py""" def __init__(self, *args, **kwargs): - raise NotImplementedError( - "plankapy.v1 is not available in this build; use plankapy.v2" - ) + raise NotImplementedError("plankapy.v1 is not available in this build; use plankapy.v2") class _DummyPasswordAuth: """Stub for plankapy.v1.PasswordAuth to satisfy root __init__.py""" def __init__(self, *args, **kwargs): - raise NotImplementedError( - "plankapy.v1 is not available in this build; use plankapy.v2" - ) + raise NotImplementedError("plankapy.v1 is not available in this build; use plankapy.v2") # Create a dummy v1 module to satisfy plankapy's root __init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..eec6b54 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,75 @@ +"""Tests for planka-cli.""" + +from typer.testing import CliRunner + +from scripts.planka_cli import app + +runner = CliRunner() + + +class TestHelp: + """Test help output for all commands.""" + + def test_main_help(self): + """Main command should show help.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Planka CLI" in result.output + + def test_projects_help(self): + """Projects subcommand should show help.""" + result = runner.invoke(app, ["projects", "--help"]) + assert result.exit_code == 0 + assert "Manage projects" in result.output + + def test_boards_help(self): + """Boards subcommand should show help.""" + result = runner.invoke(app, ["boards", "--help"]) + assert result.exit_code == 0 + assert "Manage boards" in result.output + + def test_lists_help(self): + """Lists subcommand should show help.""" + result = runner.invoke(app, ["lists", "--help"]) + assert result.exit_code == 0 + assert "Manage lists" in result.output + + def test_cards_help(self): + """Cards subcommand should show help.""" + result = runner.invoke(app, ["cards", "--help"]) + assert result.exit_code == 0 + assert "Manage cards" in result.output + + def test_notifications_help(self): + """Notifications subcommand should show help.""" + result = runner.invoke(app, ["notifications", "--help"]) + assert result.exit_code == 0 + assert "Manage notifications" in result.output + + +class TestUnknownCommand: + """Test behavior with unknown commands.""" + + def test_unknown_subcommand_shows_help(self): + """Unknown subcommand should show help and exit with code 2.""" + result = runner.invoke(app, ["unknown"]) + assert result.exit_code == 2 + + +class TestLoginLogout: + """Test login/logout commands (without actually connecting).""" + + def test_login_requires_options(self): + """Login without options should fail.""" + result = runner.invoke(app, ["login"]) + assert result.exit_code != 0 + + def test_logout_without_credentials(self, tmp_path, monkeypatch): + """Logout without stored credentials should be graceful.""" + # Point ENV_PATH to a temp location + import scripts.planka_cli as cli_module + + monkeypatch.setattr(cli_module, "ENV_PATH", tmp_path / ".env") + result = runner.invoke(app, ["logout"]) + assert result.exit_code == 0 + assert "No stored credentials" in result.output diff --git a/uv.lock b/uv.lock index ec7704c..d8d4899 100644 --- a/uv.lock +++ b/uv.lock @@ -103,6 +103,15 @@ 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 = "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 = "macholib" version = "1.16.4" @@ -169,6 +178,8 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pyinstaller" }, + { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] @@ -180,7 +191,11 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pyinstaller" }] +dev = [ + { name = "pyinstaller" }, + { name = "pytest" }, + { name = "ruff" }, +] [[package]] name = "plankapy" @@ -209,6 +224,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/57/db8f36d86e649ec19e1978a665c6b6cde945f9d72f28f326154d3705562b/plankapy-2.3.0-py3-none-any.whl", hash = "sha256:699532cfff4e5ca7e47409d17ff64c34aff85a41ea14887a87adc1770b3c1066", size = 135703, upload-time = "2026-01-27T00:11:41.456Z" }, ] +[[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" @@ -259,6 +283,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/b1/9da6ec3e88696018ee7bb9dc4a7310c2cfaebf32923a19598cd342767c10/pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5", size = 452318, upload-time = "2026-01-20T00:15:21.88Z" }, ] +[[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 = "python-dotenv" version = "1.2.1" @@ -290,6 +330,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952, upload-time = "2026-01-24T21:40:42.969Z" }, ] +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + [[package]] name = "setuptools" version = "80.10.2"