diff --git a/.github/workflows/dependabot-uv-lock.yml b/.github/workflows/dependabot-uv-lock.yml index 49cd366..75df779 100644 --- a/.github/workflows/dependabot-uv-lock.yml +++ b/.github/workflows/dependabot-uv-lock.yml @@ -1,11 +1,9 @@ name: "Dependabot: Update uv.lock" permissions: contents: read - on: pull_request_target: types: [opened, synchronize] - jobs: lock: runs-on: ubuntu-latest @@ -15,21 +13,17 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} - - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true - - name: Update the lockfile run: uv lock - - name: Upload uv.lock uses: actions/upload-artifact@v4 with: name: uv-lock path: uv.lock - commit: needs: lock runs-on: ubuntu-latest @@ -42,12 +36,10 @@ jobs: with: ref: ${{ github.head_ref }} token: ${{ secrets.DEPENDABOT_PAT }} - - name: Download uv.lock uses: actions/download-artifact@v7 with: name: uv-lock - - name: Commit and push changes run: | git config --global user.name "github-actions[bot]" diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 7a5cb5e..13e1f3a 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -19,6 +19,8 @@ jobs: uses: astral-sh/setup-uv@v7 with: enable-cache: true + - name: Install pngquant for image optimization + run: sudo apt-get update && sudo apt-get install -y pngquant - name: Install dependencies run: uv sync --all-groups --all-extras --frozen - name: Test MkDocs Build (Strict) diff --git a/.gitignore b/.gitignore index fd6ee67..99aa85c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ prusa_tokens.json .idea/ .vscode/ .coverage +pytest.xml +pytest-coverage.txt .DS_Store .pytest_cache/ .mypy_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9890021..50e5432 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,10 +8,13 @@ repos: - mdformat-mkdocs[recommended] - mdformat-ruff - mdformat_frontmatter + exclude: ^docs/cli/reference\.md$ - repo: https://github.com/jackdewinter/pymarkdown rev: v0.9.35 # Use the latest version hooks: - id: pymarkdown + name: pymarkdown + args: ["-d", "MD041,MD002", "scan"] # Let mdformat handle frontmatter - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: diff --git a/.python-version b/.python-version deleted file mode 100644 index 6324d40..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.14 diff --git a/docs/.meta.yml b/docs/.meta.yml deleted file mode 100644 index f9defc8..0000000 --- a/docs/.meta.yml +++ /dev/null @@ -1 +0,0 @@ -comments: true diff --git a/docs/api/.meta.yml b/docs/api/.meta.yml deleted file mode 100644 index 651ace8..0000000 --- a/docs/api/.meta.yml +++ /dev/null @@ -1 +0,0 @@ -comments: false diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..80fd5e0 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,83 @@ +# Authentication + +Authentication is a crucial part of interacting with the Prusa Connect API. The +SDK handles authentication tokens automatically once you've logged in. + +## How it Works + +The SDK uses the same OAuth 2.0 flow as the official Prusa Connect web interface +to obtain access and refresh tokens. It uses the PKCE (Proof Key for Code +Exchange) extension to securely obtain tokens without a client secret (kudos to +the Prusa Connect team for doing things right and implementing this! :star2:). + +1. **Identity Token**: Provides identity information about the user. +2. **Access Token**: Used to authenticate API requests. Valid for a short period + (usually 1 hour). +3. **Refresh Token**: Used to obtain new access tokens when they expire. Valid + for a longer period (usually 30 days). + +## Storing Credentials + +The SDK stores tokens securely in a user-specific configuration directory using +`platformdirs`. + +- **Linux**: `~/.config/prusa-connect-sdk-client/tokens.json` (or similar) +- **macOS**: + `~/Library/Application Support/prusa-connect-sdk-client/tokens.json` +- **Windows**: `%APPDATA%\prusa-connect-sdk-client\tokens.json` + +The file is JSON-formatted and contains the tokens. It is recommended to +restrict access to this file. + +## Authentication Methods + +### 1. CLI Authentication (Recommended) + +Run `prusactl auth login` to start an interactive login session. This will +prompt for your email, password, and 2FA code (if enabled). + +### 2. Environment Variables + +You can also provide credentials via environment variables, although this is +generally less secure for long-term use. + +- `PRUSA_EMAIL`: Your Prusa Account email. +- `PRUSA_PASSWORD`: Your Prusa Account password. + +Note: Environment variables are used for initial login if provided, but tokens +are preferred. + +### 3. Headless / CI/CD Authentication + +For automated environments where interactive login isn't possible, set one of +the following environment variables. `PrusaConnectClient` detects them +automatically — no call to `auth login` required. + +| Variable | Value | +| ------------------- | ------------------------------------------------ | +| `PRUSA_TOKEN` | Raw JWT access token string (`ey...`) | +| `PRUSA_TOKENS_JSON` | Full token JSON object (access + refresh tokens) | + +=== "Raw token" + + ```bash + export PRUSA_TOKEN="ey..." + ``` + +=== "Token JSON" + + ```bash + export PRUSA_TOKENS_JSON='{"access_token": "ey...", "refresh_token": "ey..."}' + ``` + +!!! tip "Getting the raw token" + + Run `prusactl auth print-access-token` to print the current access token to + stdout. This is useful for seeding `PRUSA_TOKEN` in a secrets manager or CI + environment variable. + +### 4. Programmatic Authentication + +You can use `prusa.connect.client.auth.interactive_login` to perform the login +flow in your own application. See the [SDK Quickstart](sdk/quickstart.md) for an +example. diff --git a/docs/cli/quickstart.md b/docs/cli/quickstart.md new file mode 100644 index 0000000..f21b34e --- /dev/null +++ b/docs/cli/quickstart.md @@ -0,0 +1,135 @@ +# CLI Quickstart + +This guide assumes you have already installed `prusactl` using `pipx`. If not, +please see the [Installation](../installation.md) guide. + +## Step 1: Authenticate + +Run the following command in your terminal to log in to your Prusa Account. This +will save a secure token locally to your user configuration directory. + +```bash +prusactl auth login +``` + +*Follow the interactive prompts to enter your credentials and 2FA code (if +required).* + +The CLI will display a success message and save your credentials securely. + +## Step 2: Verify Authentication + +You can check your current authentication status at any time: + +```bash +prusactl auth show +``` + +## Step 3: List Your Printers + +Now that you are authenticated, list your printers: + +```bash +prusactl printer list +``` + +## Step 4: Set a Default Printer + +Most commands require a printer UUID. To avoid typing it every time, set a +default: + +```bash +prusactl printer set-current +``` + +Once set, commands like `prusactl printer show`, `prusactl stats usage`, and +others will automatically use this printer. + +## Step 5: Monitor Printer Statistics + +Track usage over time with the `stats` command group: + +```bash +# Printing time vs idle time for the last 7 days +prusactl stats usage + +# Material consumption +prusactl stats material --days 30 + +# Job success/failure breakdown +prusactl stats jobs + +# Planned task schedule (hour-by-hour heatmap) +prusactl stats planned +``` + +All `stats` subcommands accept `--from` and `--to` date flags for custom date +ranges, and `--days N` as a shorthand for the last N days. + +## Step 6: Work with Teams and Cameras + +List the teams you belong to and manage cameras: + +```bash +# Teams +prusactl team list +prusactl team show # Show default team details + +# Cameras +prusactl camera list +prusactl camera snapshot --output snapshot.jpg +prusactl camera show +``` + +## Step 7: Configure Defaults + +Set defaults for team and camera to avoid passing IDs repeatedly: + +```bash +prusactl team set-current +prusactl camera set-current +``` + +## Step 8: Enable Shell Completion + +`prusactl` supports tab completion for all commands and flags. Install it for +your shell: + +```bash +prusactl --install-completion +``` + +Restart your shell (or source your profile) to activate it. + +## Step 9: Explore Commands + +Use the `--help` flag to discover available commands and flags at any level: + +```bash +prusactl --help +prusactl printer --help +prusactl stats --help +``` + +## Configuration File + +Settings like default printer, team, and camera IDs are stored in a JSON file in +your platform config directory: + +| Platform | Path | +| -------- | -------------------------------------------------------------------- | +| Linux | `~/.config/prusa-connect-sdk-client/config.json` | +| macOS | `~/Library/Application Support/prusa-connect-sdk-client/config.json` | +| Windows | `%APPDATA%\prusa-connect-sdk-client\config.json` | + +You can edit this file directly. Supported keys: + +```json +{ + "default_printer_id": "your-printer-uuid", + "default_team_id": 12345, + "default_camera_id": "your-camera-id" +} +``` + +Environment variables (e.g. `DEFAULT_PRINTER_ID`) override file values. diff --git a/docs/cli_reference.md b/docs/cli/reference.md similarity index 75% rename from docs/cli_reference.md rename to docs/cli/reference.md index 54a3c3d..1d06fe5 100644 --- a/docs/cli_reference.md +++ b/docs/cli/reference.md @@ -1,7 +1,3 @@ ---- -comments: false ---- - # CLI Reference ::: cyclopts diff --git a/docs/examples.md b/docs/examples.md index 0c4138f..d2b5cb2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -2,24 +2,10 @@ ## Headless Authentication (CI/CD) -For environments where interactive login isn't possible (like CI/CD pipelines or -servers), you can use environment variables. - -1. **Option A: Raw Token** Set the `PRUSA_TOKEN` environment variable with your - raw JWT access token. - - ```bash - export PRUSA_TOKEN="ey..." - ``` - -2. **Option B: Token JSON** Set the `PRUSA_TOKENS_JSON` environment variable - with the full JSON object containing access and refresh tokens. - - ```bash - export PRUSA_TOKENS_JSON='{"access_token": "...", "refresh_token": "..."}' - ``` - -The `PrusaConnectClient` will automatically detect these variables. +For environments where interactive login isn't possible, use the `PRUSA_TOKEN` +or `PRUSA_TOKENS_JSON` environment variables. See +[Authentication](authentication.md#3-headless-cicd-authentication) for full +details. ## Controlling a Printer diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..6545afa --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,111 @@ +# Installation + +This guide covers how to install the Unoriginal Prusa Connect Client SDK for +different use cases. + +## CLI Users (Recommended) + +If you primarily want to manage your printers from the command line, we +recommend using [pipx](https://pypa.github.io/pipx/) to install the application +in an isolated environment. + +### 1. Install pipx + +If you don't have `pipx` installed: + +=== "macOS" + + ```bash + brew install pipx + pipx ensurepath + ``` + +=== "Windows" + + ```powershell + scoop install pipx + pipx ensurepath + ``` + +=== "Linux (Debian/Ubuntu)" + + ```bash + sudo apt install pipx + pipx ensurepath + ``` + +=== "Linux (Fedora/RHEL)" + + ```bash + sudo dnf install pipx + pipx ensurepath + ``` + +=== "Linux (other)" + + ```bash + # Universal fallback using pip + pip install --user pipx + pipx ensurepath + ``` + +### 2. Install prusactl + +Install the package with the `cli` extra: + +```bash +pipx install "prusa-connect-sdk-client[cli]" +``` + +Verify the installation: + +```bash +prusactl --version +``` + +## SDK Developers + +If you want to build your own Python applications using the SDK, install the +library using `pip`, `uv`, or your preferred package manager. + +=== "pip" + + ```bash + pip install prusa-connect-sdk-client + ``` + +=== "uv" + + ```bash + uv add prusa-connect-sdk-client + ``` + +=== "poetry" + + ```bash + poetry add prusa-connect-sdk-client + ``` + +### Optional Dependencies + +The `cli` extra installs additional dependencies like `cyclopts` and `rich`. If +you plan to build your own CLI tools using this SDK, you might want to include +them. It also makes authentication easier for development. + +=== "pip" + + ```bash + pip install prusa-connect-sdk-client[cli] + ``` + +=== "uv" + + ```bash + uv add prusa-connect-sdk-client[cli] + ``` + +=== "poetry" + + ```bash + poetry add prusa-connect-sdk-client[cli] + ``` diff --git a/docs/quickstart.md b/docs/quickstart.md deleted file mode 100644 index 4f766ed..0000000 --- a/docs/quickstart.md +++ /dev/null @@ -1,95 +0,0 @@ -# Quickstart - -## CLI Quickstart - -### Step 1: Authenticate - -Run the following command in your terminal to log in to your Prusa Account. This -will save a secure token locally to your user configuration directory. - -```bash -prusactl auth login -``` - -*Follow the interactive prompts to enter your credentials and 2FA code (if -required).* - -Optional: Check your current authentication token status. - -```bash -prusactl auth show -``` - -## Python Quickstart - -### Step 1: Authenticate - -The easy button is to use the CLI to authenticate. Once you have the tokens -file, the token will refresh automatically until your refresh token expires -(seems to be around 30 days). Let's say you don't want the CLI because whatever -your personal reason is, you can use the `interactive_login` helper function -from the `auth` module. This function performs the same authentication flow as -the official Prusa Connect web app, but implemented purely in Python. - -Passing the `save_tokens` function to the `PrusaConnectCredentials` will save -the tokens to `TOKEN_PATH` - -```python -from prusa.connect.client.auth import interactive_login - -# Assume you retrieve the credentials from a secure location -CONNECT_USERNAME = "" -CONNECT_PASSWORD = "" - - -def otp_callback() -> str: - # If you have 2FA enabled, you need a callback function - # that returns the OTP code. - return input("Enter OTP: ") - - -# This will open a browser window for you to log in -token_data = interactive_login(CONNECT_USERNAME, CONNECT_PASSWORD, otp_callback) - -# You can then use the token_data to create the creds object for a client -from prusa.connect.client import PrusaConnectClient, PrusaConnectCredentials - -TOKEN_PATH = get_default_token_path() - - -def save_tokens(token_data: dict[str, Any]): - with TOKEN_PATH.open("w") as f: - import json - - json.dumps(token_data, f) - - -creds = PrusaConnectCredentials(token_data, token_saver=save_tokens) -client = PrusaConnectClient(credentials=creds) -``` - -### Step 2: Hello World - -Create a Python script (`hello_prusa.py`) to list your printers. The client -automatically loads the credentials you just saved. - -```python -from prusa.connect.client import PrusaConnectClient - -# Credentials are automatically loaded from your environment or default local file -client = PrusaConnectClient() - -print("My Printers:") -for printer in client.get_printers(): - status = printer.printer_state or "UNKNOWN" - print(f"- {printer.name} ({status})") - - if printer.telemetry: - print(f" Temp: {printer.telemetry.temp_nozzle}°C") -``` - -Run it: - -```bash -python3 hello_prusa.py -``` diff --git a/docs/sdk/quickstart.md b/docs/sdk/quickstart.md new file mode 100644 index 0000000..c8d96ea --- /dev/null +++ b/docs/sdk/quickstart.md @@ -0,0 +1,103 @@ +# SDK Quickstart + +This guide assumes you have already installed `prusa-connect-sdk-client` using +your preferred package manager. If not, please see the +[Installation](../installation.md) guide. + +## Step 1: Authentication + +The easiest way to get started is to use the CLI to authenticate. The SDK will +automatically detect and load the credentials saved by the CLI. + +### Option 1: Use the CLI (Recommended) + +Run: + +```bash +prusactl auth login +``` + +### Option 2: Programmatic Authentication + +If you cannot use the CLI or prefer to manage credentials manually, you can use +the `interactive_login` helper function. + +```python +from prusa.connect.client import auth +from prusa.connect.client import PrusaConnectClient, PrusaConnectCredentials + +# Assume you retrieve the credentials from a secure location +CONNECT_USERNAME = "my@email.com" +CONNECT_PASSWORD = "my_password" + + +def otp_callback() -> str: + # If you have 2FA enabled, you need a callback function + # that returns the OTP code. + return input("Enter OTP: ") + + +# Perform interactive login +token_data = auth.interactive_login(CONNECT_USERNAME, CONNECT_PASSWORD, otp_callback) + +# Save tokens somewhere (e.g., database, secret manager) +# Or use the default token saver if you want to store it locally +# credentials = PrusaConnectCredentials(token_data, token_saver=auth.save_tokens) + +# Or construct a credentials object manually +credentials = PrusaConnectCredentials(tokens=token_data) + +client = PrusaConnectClient(credentials=credentials) +``` + +## Step 2: Hello World + +Create a Python script (`hello_prusa.py`) to list your printers. + +```python +from prusa.connect.client import PrusaConnectClient + +# Credentials are automatically loaded from your environment or default local file +client = PrusaConnectClient() + +print("My Printers:") +for printer in client.get_printers(): + status = printer.printer_state or "UNKNOWN" + print(f"- {printer.name} ({status})") + + if printer.telemetry: + print(f" Temp: {printer.telemetry.temp_nozzle}°C") +``` + +Run it: + +```bash +python3 hello_prusa.py +``` + +## Step 3: Handle Errors + +The SDK raises typed exceptions you can catch for robust applications: + +```python +from prusa.connect.client import PrusaConnectClient +from prusa.connect.client.exceptions import PrusaApiError, PrusaNetworkError + +client = PrusaConnectClient() + +try: + printers = client.get_printers() +except PrusaApiError as e: + # HTTP error from the Prusa Connect API (4xx / 5xx) + print(f"API error {e.status_code}: {e}") + if e.response_body: + print(f"Details: {e.response_body}") +except PrusaNetworkError as e: + # Connection timeout, DNS failure, etc. + print(f"Network error: {e}") +``` + +## Step 4: Explore the API + +Check out the [API Reference](../api/client.md) for a full list of available +methods on `PrusaConnectClient`. diff --git a/examples/client_auth/client_auth.py b/examples/client_auth/client_auth.py index 80f8703..2e3d0e2 100644 --- a/examples/client_auth/client_auth.py +++ b/examples/client_auth/client_auth.py @@ -41,4 +41,4 @@ def save_tokens(data): client = PrusaConnectClient(credentials=creds) # 3. Use it (Token refresh happens automatically in background if needed) -printers = client.get_printers() +printers = client.printers.list_printers() diff --git a/examples/hello_world/hello_prusa.py b/examples/hello_world/hello_prusa.py index f15026e..f613f8a 100644 --- a/examples/hello_world/hello_prusa.py +++ b/examples/hello_world/hello_prusa.py @@ -6,7 +6,7 @@ client = PrusaConnectClient() print("My Printers:") -for printer in client.get_printers(): +for printer in client.printers.list_printers(): status = printer.printer_state or "UNKNOWN" print(f"- {printer.name} ({status})") diff --git a/mkdocs.yml b/mkdocs.yml index 4e2340d..59b412b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ theme: - content.action.edit - content.code.copy - navigation.sections + - content.tabs.link markdown_extensions: - admonition - attr_list @@ -21,16 +22,23 @@ markdown_extensions: - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower plugins: - search - optimize: - enabled: !ENV [CI, false] + enabled: false - git-revision-date-localized: enabled: !ENV [CI, false] type: timeago enable_creation_date: true exclude: - index.md + - cyclopts - mkdocstrings: handlers: python: @@ -48,7 +56,6 @@ plugins: branch: main enabled: !ENV [CI, false] - meta - - cyclopts - typeset exclude_docs: | development_notes/ @@ -68,13 +75,19 @@ extra: make our documentation better. nav: - Home: index.md - - Quickstart: quickstart.md - - Examples & How-Tos: examples.md - - CLI Reference: cli_reference.md - - API Reference: - - Auth: api/auth.md - - Client: api/client.md - - Models: api/models.md + - Getting Started: + - Installation: installation.md + - Authentication: authentication.md + - CLI User Guide: + - Quickstart: cli/quickstart.md + - Reference: cli/reference.md + - SDK Developer Guide: + - Quickstart: sdk/quickstart.md + - Examples: examples.md + - API Reference: + - Auth: api/auth.md + - Client: api/client.md + - Models: api/models.md - Development Resources: - Contributing: contributing.md - Architecture Notes: internal_architecture.md diff --git a/overrides/partials/comments.html b/overrides/partials/comments.html deleted file mode 100644 index 5cc9219..0000000 --- a/overrides/partials/comments.html +++ /dev/null @@ -1,44 +0,0 @@ -{% if page.meta.comments %} -

{{ lang.t("meta.comments") }}

- - - - -{% endif %} diff --git a/src/prusa/connect/client/cli/commands/api.py b/src/prusa/connect/client/cli/commands/api.py index 84601c9..613edce 100644 --- a/src/prusa/connect/client/cli/commands/api.py +++ b/src/prusa/connect/client/cli/commands/api.py @@ -1,22 +1,27 @@ """Raw API request commands.""" +from __future__ import annotations + import json -import pathlib +import pathlib # noqa: TC003 import sys import typing import cyclopts +import requests # noqa: TC002 from rich import print as rprint from prusa.connect.client.cli import common def api_command( - path: typing.Annotated[str, cyclopts.Parameter(help="API endpoint (e.g. /printers)")], + path: typing.Annotated[str, cyclopts.Parameter(help="API endpoint (e.g. /app/printers)")], method: typing.Annotated[str, cyclopts.Parameter(help="HTTP Method")] = "GET", data: typing.Annotated[str | None, cyclopts.Parameter(help="JSON data body")] = None, output: typing.Annotated[pathlib.Path | None, cyclopts.Parameter(help="Output file for response")] = None, stream: typing.Annotated[bool, cyclopts.Parameter(help="Stream response (useful for large files)")] = False, + response_headers: typing.Annotated[bool, cyclopts.Parameter(help="Print response headers", alias=["-h"])] = False, + response_body: typing.Annotated[bool, cyclopts.Parameter(help="Print response body", alias=["-b"])] = True, ): """Make a raw authenticated API request.""" common.logger.debug( @@ -39,10 +44,15 @@ def api_command( # Use raw=True if output is specified OR stream is True # If streaming, we MUST use raw to get the response object - raw_mode = (output is not None) or stream try: - res = client._request(method, path, raw=raw_mode, **kwargs) + res: requests.Response = client._request(method, path, raw=True, **kwargs) + + if response_headers: + rprint(f"{getattr(res, 'status_code', None)} {getattr(res, 'reason', None)}") + for k, v in res.headers.items(): + rprint(f"[bold]{k}:[/bold] {v}") + rprint("") if stream: # Handle streaming @@ -57,22 +67,36 @@ def api_command( sys.stdout.buffer.write(chunk) return + content_type = res.headers.get("Content-Type", "") # Normal (non-stream) handling if output: if str(output) == "-": - if hasattr(res, "content"): - sys.stdout.buffer.write(res.content) + if "application/json" in content_type.lower(): + sys.stdout.write(json.dumps(res.json())) else: - print(json.dumps(res, indent=2)) + if hasattr(res, "text"): + sys.stdout.write(res.text) + else: + sys.stdout.buffer.write(res.content) else: - if hasattr(res, "content"): - output.write_bytes(res.content) - else: + if "application/json" in content_type.lower(): with open(output, "w") as f: - json.dump(res, f, indent=2) + json.dump(res.json(), f) + else: + if hasattr(res, "text"): + with open(output, "w") as f: + f.write(res.text) + else: + output.write_bytes(res.content) rprint(f"[green]Response saved to {output}[/green]") else: - rprint(res) + if response_body: + if "application/json" in content_type.lower(): + print(json.dumps(res.json())) + elif "text" in content_type.lower(): + print(res.text) + else: + sys.stdout.buffer.write(res.content) except Exception as e: if (output and str(output) == "-") or stream: diff --git a/src/prusa/connect/client/cli/commands/auth.py b/src/prusa/connect/client/cli/commands/auth.py index 9603a2f..90542af 100644 --- a/src/prusa/connect/client/cli/commands/auth.py +++ b/src/prusa/connect/client/cli/commands/auth.py @@ -2,7 +2,6 @@ import contextlib import datetime -import getpass import json import os import sys @@ -10,31 +9,35 @@ import cyclopts from rich import print as rprint +from rich.prompt import Confirm, Prompt from rich.table import Table from prusa.connect.client import auth, exceptions from prusa.connect.client.cli import common, config +auth_app = cyclopts.App(name="auth", help="Manage authentication settings") -def _auth_login(): + +@auth_app.command(name="login") +def login_command(): """Perform interactive login.""" - rprint("Logging in to Prusa Connect...") + rprint("[bold blue]Logging in to Prusa Connect...[/bold blue]") - email = config.settings.prusa_email or os.environ.get("PRUSA_EMAIL") - if not email: - print("Email: ", end="", flush=True) - email = input().strip() + default_email = config.settings.prusa_email or os.environ.get("PRUSA_EMAIL") + email = Prompt.ask("Email", default=default_email) - password = config.settings.prusa_password or os.environ.get("PRUSA_PASSWORD") - if not password: - password = getpass.getpass("Password: ") + default_password = config.settings.prusa_password or os.environ.get("PRUSA_PASSWORD") + if default_password: + rprint("[dim]Using password from environment/config[/dim]") + password = default_password + else: + password = Prompt.ask("Password", password=True) def otp_callback() -> str: - print("Enter 2FA/TOTP Code: ", end="", flush=True) - return input().strip() + return Prompt.ask("Enter 2FA/TOTP Code") try: - token_data = auth.interactive_login(email, str(password), otp_callback=otp_callback) + token_data = auth.interactive_login(str(email), str(password), otp_callback=otp_callback) def save_tokens(data): path = config.settings.tokens_file @@ -54,7 +57,8 @@ def save_tokens(data): sys.exit(1) -def _auth_show(): +@auth_app.command(name="show") +def show_command(): """Show current authentication status.""" creds = auth.PrusaConnectCredentials.load_default() if not creds or not creds.valid: @@ -100,18 +104,22 @@ def fmt_ts(ts): common.console.print(table) -def _auth_clear(): +@auth_app.command(name="clear") +def clear_command(): """Clear saved credentials.""" path = config.settings.tokens_file if path.exists(): + if not Confirm.ask(f"Clear saved credentials at {path}?"): + rprint("[dim]Aborted.[/dim]") + return path.unlink() rprint(f"[green]Removed tokens file: {path}[/green]") else: rprint(f"[yellow]No tokens file found at {path}[/yellow]") -def _auth_print_token(kind: typing.Literal["access", "identity"]): - """Print raw token.""" +def _print_token(kind: typing.Literal["access", "identity"]): + """Helper to print raw token.""" creds = auth.PrusaConnectCredentials.load_default() # Try refresh if needed if creds and not creds.valid: @@ -135,22 +143,17 @@ def _auth_print_token(kind: typing.Literal["access", "identity"]): sys.exit(1) -def auth_command( - action: typing.Annotated[ - typing.Literal["login", "show", "clear", "print-access-token", "print-identity-token"], - cyclopts.Parameter(help="Auth action"), - ], -): - """Manage authentication settings.""" - common.logger.debug("Command started", command="auth", action=action) - - if action == "login": - _auth_login() - elif action == "show": - _auth_show() - elif action == "clear": - _auth_clear() - elif action == "print-access-token": - _auth_print_token("access") - elif action == "print-identity-token": - _auth_print_token("identity") +@auth_app.command(name="print-access-token") +def print_access_token_command(): + """Print the raw access token.""" + _print_token("access") + + +@auth_app.command(name="print-identity-token") +def print_identity_token_command(): + """Print the raw identity token.""" + _print_token("identity") + + +# Legacy alias for backward compatibility if needed, but we're replacing the command structure. +# We can export auth_app as the main interface. diff --git a/src/prusa/connect/client/cli/commands/camera.py b/src/prusa/connect/client/cli/commands/camera.py index 1d255b1..614bbb0 100644 --- a/src/prusa/connect/client/cli/commands/camera.py +++ b/src/prusa/connect/client/cli/commands/camera.py @@ -18,7 +18,7 @@ def camera_list(): """List all cameras.""" common.logger.debug("Command started", command="camera list") client = common.get_client() - cameras = client.get_cameras() + cameras = client.cameras.list() table = Table(title="Cameras") table.add_column("Name", style="cyan") @@ -51,7 +51,7 @@ def camera_snapshot( client = common.get_client() # We look up the camera to get ID - cameras = client.get_cameras() + cameras = client.cameras.list() match = next((c for c in cameras if str(c.id) == camera_id or c.token == camera_id or c.name == camera_id), None) real_id = camera_id @@ -86,7 +86,7 @@ def camera_trigger( client = common.get_client() # We look up to get token - cameras = client.get_cameras() + cameras = client.cameras.list() match = next((c for c in cameras if str(c.id) == camera_id or c.token == camera_id or c.name == camera_id), None) real_token = camera_id @@ -113,7 +113,7 @@ def camera_move( client = common.get_client() # Resolve token - cameras = client.get_cameras() + cameras = client.cameras.list() match = next((c for c in cameras if str(c.id) == camera_id or c.token == camera_id or c.name == camera_id), None) token = match.token if match and match.token else camera_id @@ -139,7 +139,7 @@ def camera_adjust( client = common.get_client() # Resolve token - cameras = client.get_cameras() + cameras = client.cameras.list() match = next((c for c in cameras if str(c.id) == camera_id or c.token == camera_id or c.name == camera_id), None) token = match.token if match and match.token else camera_id @@ -167,7 +167,52 @@ def camera_adjust( @camera_app.command(name="set-current") def set_current_camera(camera_id: typing.Annotated[str, cyclopts.Parameter(help="Camera UUID")]): - """Set the default camera UUID for future commands.""" + """Set the default camera ID for future commands.""" config.settings.default_camera_id = camera_id config.save_json_config(config.settings) rprint(f"[green]Successfully set default camera to {camera_id}[/green]") + + +@camera_app.command(name="show") +def camera_show( + camera_id: typing.Annotated[str, cyclopts.Parameter(help="Camera Token or ID or Name")], + detailed: bool = False, +): + """Show details for a specific camera.""" + common.logger.debug("Command started", command="camera show", camera_id=camera_id, detailed=detailed) + client = common.get_client() + + cameras = client.cameras.list() + match = next((c for c in cameras if str(c.id) == camera_id or c.token == camera_id or c.name == camera_id), None) + + if not match: + rprint(f"[red]Camera '{camera_id}' not found.[/red]") + sys.exit(1) + + if detailed: + from rich.panel import Panel + from rich.pretty import Pretty + + common.console.print(Panel(Pretty(match), title=f"Camera: {match.name or 'Unknown'}")) + else: + table = Table(show_header=False, box=None) + table.add_column("Property", style="bold cyan") + table.add_column("Value") + + table.add_row("Name", match.name or "N/A") + table.add_row("ID (Numeric)", str(match.id) if match.id else "N/A") + table.add_row("Token", match.token or "N/A") + table.add_row("Origin", match.origin or "N/A") + + if match.config: + if match.config.resolution: + table.add_row("Resolution", f"{match.config.resolution.width}x{match.config.resolution.height}") + if match.config.firmware: + table.add_row("Firmware", match.config.firmware) + if match.config.model: + table.add_row("Model", match.config.model) + + if match.printer_uuid: + table.add_row("Printer UUID", match.printer_uuid) + + common.console.print(table) diff --git a/src/prusa/connect/client/cli/commands/file.py b/src/prusa/connect/client/cli/commands/file.py index 521cee7..8e47132 100644 --- a/src/prusa/connect/client/cli/commands/file.py +++ b/src/prusa/connect/client/cli/commands/file.py @@ -11,6 +11,7 @@ file_app = cyclopts.App(name="file", help="File management (Connect/Team level)") +@file_app.command(name="list") def file_list( team_id: typing.Annotated[int | None, cyclopts.Parameter(help="Team ID to list files for")] = None, ): @@ -19,7 +20,7 @@ def file_list( resolved_team_id = team_id or config.settings.default_team_id if not resolved_team_id: - teams = client.get_teams() + teams = client.teams.list_teams() if not teams: common.console.print("[red]No teams found.[/red]") return @@ -60,7 +61,7 @@ def file_upload( client = common.get_client() resolved_team_id = team_id or config.settings.default_team_id if not resolved_team_id: - teams = client.get_teams() + teams = client.teams.list_teams() if not teams: common.console.print("[red]No teams found.[/red]") return @@ -105,7 +106,7 @@ def file_download( client = common.get_client() resolved_team_id = team_id or config.settings.default_team_id if not resolved_team_id: - teams = client.get_teams() + teams = client.teams.list_teams() if not teams: common.console.print("[red]No teams found.[/red]") return @@ -136,7 +137,7 @@ def file_show( client = common.get_client() resolved_team_id = team_id or config.settings.default_team_id if not resolved_team_id: - teams = client.get_teams() + teams = client.teams.list_teams() if not teams: common.console.print("[red]No teams found.[/red]") return diff --git a/src/prusa/connect/client/cli/commands/job.py b/src/prusa/connect/client/cli/commands/job.py index 27ae8b1..b70910f 100644 --- a/src/prusa/connect/client/cli/commands/job.py +++ b/src/prusa/connect/client/cli/commands/job.py @@ -40,7 +40,7 @@ def job_list( # Aggregation mode: Get jobs from ALL printers (cached) # This is preferred over iterating teams if we want "my printers" context try: - printers = client.get_printers() + printers = client.printers.list_printers() for p in printers: if not p.uuid: continue @@ -86,7 +86,7 @@ def sort_key(j): table.add_row( str(j.id), j.printer_uuid or "Unknown", - j.state or "Unknown", + j.state.name, j.file.name if j.file else "Unknown", f"{j.progress}%" if j.progress is not None else "N/A", ended_str, @@ -123,7 +123,7 @@ def job_queued( else: # Aggregate from all printers try: - printers = client.get_printers() + printers = client.printers.list_printers() for p in printers: if not p.uuid: continue diff --git a/src/prusa/connect/client/cli/commands/printer.py b/src/prusa/connect/client/cli/commands/printer.py index 656ec5d..8c88e20 100644 --- a/src/prusa/connect/client/cli/commands/printer.py +++ b/src/prusa/connect/client/cli/commands/printer.py @@ -23,7 +23,7 @@ def _send_printer_command(printer_ids: list[str], command: str): for pid in printer_ids: try: - if client.send_command(pid, command): + if client.printers.send_command(pid, command): rprint(f"[green]Sent {command} to {pid}[/green]") except Exception as e: rprint(f"[red]Failed to send {command} to {pid}: {e}[/red]") @@ -36,7 +36,7 @@ def printer_list( """List all printers associated with the account.""" common.logger.debug("Command started", command="printer list", pattern=pattern) client = common.get_client() - printers = client.get_printers() + printers = client.printers.list_printers() common.logger.info("Found printers", count=len(printers)) table = Table(title="Printers") @@ -77,14 +77,18 @@ def printer_show( """Show detailed status for a specific printer.""" resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then\n" + "'prusactl printer set-current ' to set the default.[/dim]" + ) return common.logger.debug("Command started", command="printer show", printer_id=resolved_id) client = common.get_client() try: - p = client.get_printer(resolved_id) + p = client.printers.get(resolved_id) # Basic Info Table table = Table(title=f"Printer: {p.name}") @@ -95,19 +99,6 @@ def printer_show( table.add_row("State", p.printer_state or "N/A") table.add_row("Model", p.printer_model or "N/A") - # Network Info - if p.network_info: - if p.network_info.lan_ipv4: - table.add_row("IP Address", p.network_info.lan_ipv4) - if p.network_info.hostname: - table.add_row("Hostname", p.network_info.hostname) - - # Location / Team - if p.location: - table.add_row("Location", p.location) - if p.team_name: - table.add_row("Team", p.team_name) - # Firmware fw_str = p.firmware_version or "Unknown" if p.support and p.support.latest and p.support.latest != p.firmware_version: @@ -116,6 +107,25 @@ def printer_show( fw_str += f" [yellow](Latest: {p.support.latest})[/yellow]" table.add_row("Firmware", fw_str) + # Location / Team + if p.location: + table.add_row("Location", p.location) + if p.team_name: + table.add_row("Team", p.team_name) + + # Network Info + if p.network_info: + table.add_section() + if p.network_info.hostname: + table.add_row("Hostname", p.network_info.hostname) + if p.network_info.lan_ipv4: + table.add_row("IP Address", p.network_info.lan_ipv4) + + # Last Online + if p.last_online: + last_seen = datetime.datetime.fromtimestamp(p.last_online).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") + table.add_row("Last Online", last_seen) + # Tool 1 Material (Default View) material = "N/A" # Try to find material from tools or slots @@ -130,28 +140,21 @@ def printer_show( if m and m != "---": material = f"{m} (Slot {active_slot_key})" - if material != "N/A" and material != "---": - table.add_row("Material", material) - - # Last Online - if p.last_online: - last_seen = datetime.datetime.fromtimestamp(p.last_online).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") - table.add_row("Last Online", last_seen) - + table.add_section() + table.add_row("Material", material) if p.telemetry: table.add_row("Nozzle", f"{p.telemetry.temp_nozzle}°C") table.add_row("Bed", f"{p.telemetry.temp_bed}°C") + # Job if p.job: + table.add_section() table.add_row("Job", p.job.display_name or "Unknown") table.add_row("Progress", f"{p.job.progress}%") - if p.job.time_remaining: - # Convert seconds to likely H:M:S - - m, s = divmod(p.job.time_remaining, 60) - h, m = divmod(m, 60) - time_left = f"{h}h {m}m {s}s" - table.add_row("Time Left", time_left) + if p.job.time_printing: + table.add_row("Time Printing", str(p.job.time_printing)) + if p.job.time_remaining and p.job.time_remaining.total_seconds() > 0: + table.add_row("Time Remaining", str(p.job.time_remaining)) common.console.print(table) @@ -204,8 +207,6 @@ def printer_show( if axis_table.row_count > 0: common.console.print(axis_table) - import json - rprint("\n[bold]Raw Detailed Information:[/bold]") detail_table = Table(show_header=False, box=None) for k, v in p.model_dump(mode="json").items(): @@ -287,7 +288,7 @@ def printer_stop( # We need the current job ID to set the reason # Fetch printer status to get job ID try: - p = client.get_printer(pid) + p = client.printers.get(pid) if p.job and p.job.id: # Validate reason string against Enum @@ -323,7 +324,11 @@ def printer_cancel_object( """Cancel a specific object during print.""" resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) return common.logger.debug("Command started", command="printer cancel-object", printer_id=resolved_id, object_id=object_id) @@ -349,7 +354,11 @@ def printer_move( """Move printer axis.""" resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) return common.logger.debug("Command started", command="printer move", printer_id=resolved_id) @@ -373,7 +382,11 @@ def printer_flash( """Flash firmware from a file on the printer's storage.""" resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) return common.logger.debug("Command started", command="printer flash", printer_id=resolved_id, file_path=file_path) @@ -394,7 +407,11 @@ def printer_commands( """List supported commands for a specific printer.""" resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) return common.logger.debug("Command started", command="printer commands", printer_id=resolved_id) @@ -489,7 +506,11 @@ def printer_execute_command( """ resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) return common.logger.debug( @@ -597,7 +618,11 @@ def printer_storages( """List storage devices attached to a printer.""" resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) return client = common.get_client() @@ -631,7 +656,11 @@ def printer_files_list( """List files on the printer's storage.""" resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) return client = common.get_client() @@ -664,26 +693,17 @@ def printer_files_upload( """Upload a file to a printer's storage.""" resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) return client = common.get_client() try: - # We need the team ID for the printer - # A simple way is to fetch printer details - p = client.get_printer(resolved_id) - # We need the numeric team_id. Wait, does Printer model have team_id? - # models.py shows Team model has id, but Printer has team_name. - # I'll check my 'get_teams' list if I don't have it in Printer. - # Actually, let's look at Printer model in models.py. - - # If we don't have team_id in Printer, we might need to find it by team_name if it matches. - # Or maybe the API for uploads supports printer_uuid? - # Sample JS shows /app/users/teams/159691/uploads - - # Let's see if we can get the team_id from the printer listing or details. - - teams = client.get_teams() + p = client.printers.get(resolved_id) + teams = client.teams.list_teams() # Find team by team_name target_team = next((t for t in teams if t.name == p.team_name), None) if not target_team and teams: @@ -711,13 +731,17 @@ def printer_files_download( """Download a file that belongs to a printer's team.""" resolved_id = printer_id or config.settings.default_printer_id if not resolved_id: - rprint("[red]No printer ID provided and no default configured.[/red]") + rprint( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) return client = common.get_client() try: - p = client.get_printer(resolved_id) - teams = client.get_teams() + p = client.printers.get(resolved_id) + teams = client.teams.list_teams() target_team = next((t for t in teams if t.name == p.team_name), None) if not target_team and teams: target_team = teams[0] diff --git a/src/prusa/connect/client/cli/commands/stats.py b/src/prusa/connect/client/cli/commands/stats.py new file mode 100644 index 0000000..28bbe4e --- /dev/null +++ b/src/prusa/connect/client/cli/commands/stats.py @@ -0,0 +1,187 @@ +"""Printer statistics commands.""" + +import datetime +import typing + +import cyclopts +from rich.table import Table + +from prusa.connect.client.cli import common, config + +stats_app = cyclopts.App(name="stats", help="Printer statistics") +logger = common.logger + + +@stats_app.command(name="usage") +def stats_usage( + printer_id: typing.Annotated[str | None, cyclopts.Parameter(help="Printer UUID")] = None, + days: typing.Annotated[int, cyclopts.Parameter(help="Number of days to look back")] = 7, + from_date: typing.Annotated[ + datetime.date | None, cyclopts.Parameter(name=["--from", "-f"], help="Start date") + ] = None, + to_date: typing.Annotated[datetime.date | None, cyclopts.Parameter(name=["--to", "-t"], help="End date")] = None, +): + """Show printer usage statistics (printing vs not printing).""" + resolved_id = printer_id or config.settings.default_printer_id + if not resolved_id: + common.console.print( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then\n" + "'prusactl printer set-current ' to set the default.[/dim]" + ) + return + + client = common.get_client() + if not from_date: + from_date = datetime.date.today() - datetime.timedelta(days=days) + if not to_date: + to_date = datetime.date.today() + + try: + stats = client.get_printer_usage_stats(resolved_id, from_time=from_date, to_time=to_date) + table = Table(title=f"Usage Stats for {stats.printer_name} ({from_date} to {to_date})") + table.add_column("Type", style="cyan") + table.add_column("Value", style="magenta") + + for entry in stats.data: + table.add_row(entry.name, str(entry.value)) + + common.console.print(table) + except Exception as e: + common.console.print(f"[red]Error:[/red] {e}") + + +@stats_app.command(name="material") +def stats_material( + printer_id: typing.Annotated[str | None, cyclopts.Parameter(help="Printer UUID")] = None, + days: typing.Annotated[int, cyclopts.Parameter(help="Number of days to look back")] = 7, + from_date: typing.Annotated[ + datetime.date | None, cyclopts.Parameter(name=["--from", "-f"], help="Start date") + ] = None, + to_date: typing.Annotated[datetime.date | None, cyclopts.Parameter(name=["--to", "-t"], help="End date")] = None, +): + """Show material quantity statistics.""" + resolved_id = printer_id or config.settings.default_printer_id + if not resolved_id: + common.console.print( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) + return + + client = common.get_client() + if not from_date: + from_date = datetime.date.today() - datetime.timedelta(days=days) + if not to_date: + to_date = datetime.date.today() + + try: + stats = client.get_printer_material_stats(resolved_id, from_time=from_date, to_time=to_date) + + table = Table(title=f"Material Stats for {stats.printer_name} ({from_date} to {to_date})") + table.add_column("Material", style="cyan") + table.add_column("Usage", style="magenta") + + if not stats.data: + table.add_row("No data available", "") + else: + for entry in stats.data: + if isinstance(entry, dict): + table.add_row(entry.get("name", "Unknown"), str(entry.get("value", "N/A"))) + else: + table.add_row("Raw Data", str(entry)) + + common.console.print(table) + except Exception as e: + common.console.print(f"[red]Error:[/red] {e}") + + +@stats_app.command(name="jobs") +def stats_jobs( + printer_id: typing.Annotated[str | None, cyclopts.Parameter(help="Printer UUID")] = None, + days: typing.Annotated[int, cyclopts.Parameter(help="Number of days to look back")] = 7, + from_date: typing.Annotated[ + datetime.date | None, cyclopts.Parameter(name=["--from", "-f"], help="Start date") + ] = None, + to_date: typing.Annotated[datetime.date | None, cyclopts.Parameter(name=["--to", "-t"], help="End date")] = None, +): + """Show job success statistics.""" + resolved_id = printer_id or config.settings.default_printer_id + if not resolved_id: + common.console.print( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) + return + + client = common.get_client() + if not from_date: + from_date = datetime.date.today() - datetime.timedelta(days=days) + if not to_date: + to_date = datetime.date.today() + + try: + stats = client.get_printer_jobs_success_stats(resolved_id, from_time=from_date, to_time=to_date) + + # Sort stats by JobStatus enum order + stats.series.sort(key=lambda x: x.status) + + logger.debug("Job Stats", data=stats) + table = Table(title=f"Job Success Stats for {stats.printer_name} ({from_date} to {to_date})") + table.add_column("Status", style="cyan") + + for date in stats.date_axis: + table.add_column(date, style="magenta") + + for series in stats.series: + row = [series.status.name] + row.extend(str(v) for v in series.data) + table.add_row(*row) + + common.console.print(table) + except Exception as e: + common.console.print(f"[red]Error:[/red] {e}") + + +@stats_app.command(name="planned") +def stats_planned( + printer_id: typing.Annotated[str | None, cyclopts.Parameter(help="Printer UUID")] = None, + days: typing.Annotated[int, cyclopts.Parameter(help="Number of days to look back")] = 7, + from_date: typing.Annotated[ + datetime.date | None, cyclopts.Parameter(name=["--from", "-f"], help="Start date") + ] = None, + to_date: typing.Annotated[datetime.date | None, cyclopts.Parameter(name=["--to", "-t"], help="End date")] = None, +): + """Show planned tasks statistics.""" + resolved_id = printer_id or config.settings.default_printer_id + if not resolved_id: + common.console.print( + "[red]No printer ID provided and no default configured.[/red]\n" + "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default.[/dim]" + ) + return + + client = common.get_client() + if not from_date: + from_date = datetime.date.today() - datetime.timedelta(days=days) + if not to_date: + to_date = datetime.date.today() + + try: + stats = client.get_printer_planned_tasks_stats(resolved_id, from_time=from_date, to_time=to_date) + table = Table(title=f"Planned Tasks for {stats.series.printer_name} ({from_date} to {to_date})") + table.add_column("Hour (UTC)", style="cyan") + table.add_column("Count", style="magenta") + + if stats.series and stats.series.data: + for hour, count in stats.series.data: + table.add_row(f"{hour:02d}:00", str(count)) + else: + table.add_row("No data available", "") + + common.console.print(table) + except Exception as e: + common.console.print(f"[red]Error:[/red] {e}") diff --git a/src/prusa/connect/client/cli/commands/team.py b/src/prusa/connect/client/cli/commands/team.py index adb9c8c..7426405 100644 --- a/src/prusa/connect/client/cli/commands/team.py +++ b/src/prusa/connect/client/cli/commands/team.py @@ -8,6 +8,7 @@ from rich.table import Table from prusa.connect.client.cli import common, config +from prusa.connect.client.cli.commands.job import job_list team_app = cyclopts.App(name="team", help="Team management") @@ -16,7 +17,7 @@ def list_teams(): """List all teams the user belongs to.""" client = common.get_client() - teams = client.get_teams() + teams = client.teams.list_teams() table = Table(title="Teams") table.add_column("ID", style="cyan") @@ -49,7 +50,7 @@ def show_team( client = common.get_client() try: - team = client.get_team(team_id_to_use) + team = client.teams.get(team_id_to_use) except Exception as e: rprint(f"[red]Error fetching team {team_id_to_use}: {e}[/red]") sys.exit(1) @@ -161,3 +162,19 @@ def set_current_team( def teams_alias(): """List all teams (alias for 'team list').""" list_teams() + + +@team_app.command(name="jobs") +def team_jobs_alias( + team: typing.Annotated[int | None, cyclopts.Parameter(help="Team ID")] = None, + printer: typing.Annotated[str | None, cyclopts.Parameter(help="Printer UUID")] = None, + state: typing.Annotated[list[str] | None, cyclopts.Parameter(help="Job state")] = None, + limit: typing.Annotated[int | None, cyclopts.Parameter(help="Limit number of jobs")] = None, +): + """List jobs (alias for 'job list').""" + team_id_to_use = team or config.settings.default_team_id + if team_id_to_use is None: + rprint("[red]Error: Team ID not provided and no default is set.[/red]") + sys.exit(1) + + job_list(team=team_id_to_use, printer=printer, state=state, limit=limit) diff --git a/src/prusa/connect/client/cli/config.py b/src/prusa/connect/client/cli/config.py index b894a5c..95f51c0 100644 --- a/src/prusa/connect/client/cli/config.py +++ b/src/prusa/connect/client/cli/config.py @@ -66,9 +66,9 @@ def settings_customise_sources( """Customise settings sources to include config.json.""" return ( init_settings, - pydantic_settings.InitSettingsSource(settings_cls, load_json_config()), env_settings, dotenv_settings, + pydantic_settings.InitSettingsSource(settings_cls, load_json_config()), file_secret_settings, ) diff --git a/src/prusa/connect/client/cli/main.py b/src/prusa/connect/client/cli/main.py index 783c8ed..7bf1875 100644 --- a/src/prusa/connect/client/cli/main.py +++ b/src/prusa/connect/client/cli/main.py @@ -1,12 +1,13 @@ """Main entry point for the CLI.""" import sys +import typing import cyclopts from prusa.connect.client import __version__ from prusa.connect.client.cli import common -from prusa.connect.client.cli.commands import api, auth, camera, file, job, printer, team +from prusa.connect.client.cli.commands import api, auth, camera, file, job, printer, stats, team # Define the App app = cyclopts.App( @@ -24,6 +25,7 @@ app.command(job.job_app) app.command(file.file_app) app.command(team.team_app) +app.command(stats.stats_app) # Register Aliases and Commands app.command(printer.printers_alias, name="printers") @@ -32,7 +34,30 @@ app.command(file.files_alias, name="files") app.command(team.teams_alias, name="teams") app.command(api.api_command, name="api") -app.command(auth.auth_command, name="auth") +app.command(auth.auth_app) + + +@app.meta.default +def entry_point( + tokens: typing.Annotated[list[str] | None, cyclopts.Parameter(show=False, allow_leading_hyphen=True)] = None, + verbose: typing.Annotated[ + bool, cyclopts.Parameter(name=["--verbose", "-v"], help="Enable verbose logging") + ] = False, + debug: typing.Annotated[bool, cyclopts.Parameter(name=["--debug"], help="Enable debug logging")] = False, +): + """Main entry point handling global flags.""" + # Configure logging + common.configure_logging(verbose, debug) + + if tokens is None: + tokens = [] + # Let cyclopts handle the full command parsing (subcommands, help, etc) + try: + app(tokens) + except cyclopts.exceptions.CycloptsError as e: + # Standard cyclopts error handling + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) def main(args: list[str] | None = None): @@ -40,30 +65,24 @@ def main(args: list[str] | None = None): if args is None: args = sys.argv[1:] - # Handle global logging flags early and robustly. - # We use argparse.parse_known_args to extract just the flags we care about - # without failing on unknown subcommand flags (like --detailed). - import argparse - - parser = argparse.ArgumentParser(add_help=False) - parser.add_argument("-v", "--verbose", action="store_true") - parser.add_argument("--no-verbose", dest="verbose", action="store_false") - parser.add_argument("--debug", action="store_true") - parser.add_argument("--no-debug", dest="debug", action="store_false") - parser.set_defaults(verbose=False, debug=False) - - parsed_globals, remaining = parser.parse_known_args(args) - - # Configure logging - common.configure_logging(parsed_globals.verbose, parsed_globals.debug) - - # Let cyclopts handle the full command parsing (subcommands, help, etc) try: - app(remaining) + app.meta(args) except cyclopts.exceptions.CycloptsError as e: - # Standard cyclopts error handling print(f"Error: {e}", file=sys.stderr) sys.exit(1) + except Exception as e: + from prusa.connect.client import exceptions + + if isinstance(e, exceptions.PrusaApiError): + print(f"API Error: {e}", file=sys.stderr) + if e.response_body: + print(f"Details: {e.response_body}", file=sys.stderr) + elif isinstance(e, exceptions.PrusaNetworkError): + print(f"Network Error: {e}", file=sys.stderr) + else: + print(f"Unexpected Error: {e}", file=sys.stderr) + common.logger.exception("An unexpected error occurred") + sys.exit(1) if __name__ == "__main__": diff --git a/src/prusa/connect/client/consts.py b/src/prusa/connect/client/consts.py index a049b22..0243182 100644 --- a/src/prusa/connect/client/consts.py +++ b/src/prusa/connect/client/consts.py @@ -9,7 +9,7 @@ APP_AUTHOR = "Prusa" # API Defaults -DEFAULT_BASE_URL = "https://connect.prusa3d.com/app" +DEFAULT_BASE_URL = "https://connect.prusa3d.com/" DEFAULT_TIMEOUT = 30.0 # Authentication Endpoints diff --git a/src/prusa/connect/client/models.py b/src/prusa/connect/client/models.py deleted file mode 100644 index 503be10..0000000 --- a/src/prusa/connect/client/models.py +++ /dev/null @@ -1,535 +0,0 @@ -"""Pydantic models for Prusa Connect API responses. - -This module defines the data structures used by the client to parse -API responses into typed objects. - -How to use the most important parts: -- Explore these models (`Printer`, `Job`, `Camera`, `File`) to understand the attributes available when - leveraging the `PrusaConnectClient`. -- `WarnExtraFieldsModel`: Base class used to log warnings if the Prusa Connect API adds new fields not yet - documented in this SDK. - -Note: - Models of API responses are subclassed from WarnExtraFieldsModel. If a - response contains fields that are not present in the model, a warning will be - logged. If the log level is set to DEBUG, the full response will be logged - so that the user can see the extra fields and decide whether to update the - model. Users should file an issue with this content on the GitHub repository - if they encounter unexpected extra fields. - - Pull requests with updated models are welcome! :thumbsup: -""" - -import datetime -import enum -import logging -import typing -import uuid as uuid_pkg - -import pydantic - -from prusa.connect.client import consts - -__all__ = [ - "Camera", - "CancelableObject", - "File", - "FirmwareFile", - "FirmwareFileMeta", - "Job", - "JobFailureReason", - "JobInfo", - "Owner", - "PrintFile", - "PrintFileMeta", - "PrinterState", - "RegularFile", - "SourceInfo", - "Storage", - "SyncInfo", - "Team", - "Temperatures", - "UploadStatus", -] - -logger = logging.getLogger(__name__) - - -class WarnExtraFieldsModel(pydantic.BaseModel): - """Base model that logs a warning if extra fields are present.""" - - model_config = pydantic.ConfigDict(extra="allow") - - def __init__(self, **data: typing.Any): - super().__init__(**data) - if self.__pydantic_extra__: - logger.warning( - f"Model {self.__class__.__name__} received unknown fields: {list(self.__pydantic_extra__.keys())}" - ) - - -class PrinterState(enum.StrEnum): - """Enum representing the possible states of a printer.""" - - IDLE = "IDLE" - PRINTING = "PRINTING" - ATTENTION = "ATTENTION" - FINISHED = "FINISHED" - STOPPED = "STOPPED" - ERROR = "ERROR" - READY = "READY" - BUSY = "BUSY" - OFFLINE = "OFFLINE" - # Fallback for unknown states - UNKNOWN = "UNKNOWN" - - @classmethod - def _missing_(cls, value: object) -> typing.Any: - return cls.UNKNOWN - - -class PrinterCommand(enum.StrEnum): - """Enum representing known commands for a printer. - - NOTE: These commands are the subset of commands on a - MK4S printer that do not require any additional parameters. - - TODO(dcode): Add support for commands that require additional parameters. - TODO(dcode): Consider dynamic command generation from the printer's capabilities. - """ - - SET_PRINTER_READY = "SET_PRINTER_READY" - CANCEL_PRINTER_READY = "CANCEL_PRINTER_READY" - PAUSE_PRINT = "PAUSE_PRINT" - RESUME_PRINT = "RESUME_PRINT" - STOP_PRINT = "STOP_PRINT" - RESET_PRINTER = "RESET_PRINTER" - UNLOAD_FILAMENT = "UNLOAD_FILAMENT" - SEND_INFO = "SEND_INFO" - STOP_TRANSFER = "STOP_TRANSFER" - SEND_STATE_INFO = "SEND_STATE_INFO" - RESET = "RESET" - DISABLE_STEPPERS = "DISABLE_STEPPERS" - BEEP = "BEEP" - # Fallback for unknown states - UNKNOWN = "UNKNOWN" - - @classmethod - def _missing_(cls, value: object) -> typing.Any: - return cls.UNKNOWN - - -class JobFailureTag(enum.StrEnum): - """Enum representing reasons for job failure/cancellation.""" - - IGNORED = "IGNORED" - CLOGGED_NOZZLE = "CLOGGED_NOZZLE" - NON_ADHERENT_BED = "NON_ADHERENT_BED" - UNDER_EXTRUSION = "UNDER_EXTRUSION" - OVER_EXTRUSION = "OVER_EXTRUSION" - STRINGING_OR_OOZING = "STRINGING_OR_OOZING" - GAPS_IN_THIN_WALLS = "GAPS_IN_THIN_WALLS" - OVERHEATING = "OVERHEATING" - LAYER_SHIFTING = "LAYER_SHIFTING" - SPAGHETTI_MONSTER = "SPAGHETTI_MONSTER" - LAYER_SEPARATION = "LAYER_SEPARATION" - WARPING = "WARPING" - POOR_BRIDGING = "POOR_BRIDGING" - OTHER = "OTHER" - - -class SourceInfo(WarnExtraFieldsModel): - """Information about the source of an action or object (e.g., user).""" - - id: int | None = None - first_name: str | None = None - last_name: str | None = None - public_name: str | None = None - avatar: str | None = None - - @pydantic.field_validator("avatar") - @classmethod - def resolve_avatar_url(cls, v: typing.Any) -> typing.Any: - """Automatically prepend MEDIA_BASE_URL if missing.""" - if isinstance(v, str) and v and not v.startswith(("http://", "https://")): - return f"{consts.MEDIA_BASE_URL}{v.lstrip('/')}" - return v - - -class Owner(SourceInfo): - """Represents the owner of a resource (same fields as SourceInfo).""" - - pass - - -class SyncInfo(WarnExtraFieldsModel): - """Synchronization details for a resource.""" - - synced_by: dict[str, typing.Any] | None = None - synced: datetime.datetime | None = None - source: str | None = None - - -class Storage(WarnExtraFieldsModel): - """Represents a storage device on the printer.""" - - type: str - path: str - mountpoint: str | None = None - name: str - read_only: bool = False - is_sfn: bool | None = None - file_count: int | None = None - free_space: int | None = None - total_space: int | None = None - - -class PrintFileMeta(WarnExtraFieldsModel): - """Metadata associated with a print file (statistics parse from G-code).""" - - model_config = pydantic.ConfigDict(extra="allow") - - extruder_colour: str | None = None - filament_abrasive: bool | None = None - temperature: int | None = None - brim_width: int | None = None - bed_temperature: int | None = None - ironing: bool | None = None - nozzle_high_flow: bool | None = None - support_material: bool | None = None - filament_type: str | None = None - filament_cost: float | None = None - total_height: float | None = None - max_layer_z: float | None = None - filament_used_m: float | None = None - filament_used_mm: float | None = None - filament_used_g: float | None = None - filament_used_cm3: float | None = None - filament_used_mm3: float | None = None - nozzle_diameter: float | None = None - fill_density: str | None = None - printer_model: str | None = None - estimated_print_time: datetime.timedelta | None = None - estimated_printing_time_normal_mode: str | None = None - layer_height: float | None = None - producer: str | None = None - slots: list[dict[str, typing.Any]] | None = None - objects_info: dict[str, typing.Any] | None = None - - -class FirmwareFileMeta(WarnExtraFieldsModel): - """Metadata associated with a firmware file.""" - - device_type_id: str | None = None - version: str | None = None - sem_ver: str | None = None - build_no: int | None = None - bbf_version: int | None = None - printer_model: str | None = None - - -class BaseFile(WarnExtraFieldsModel): - """Common fields for all file types.""" - - type: str # Discriminator field - name: str - display_name: str | None = None - size: pydantic.ByteSize | None = None - hash: str | None = None - - team_id: int | None = None - upload_id: int | None = None - uploaded: datetime.datetime | None = None - - path: str | None = None - display_path: str | None = None - read_only: bool = False - m_timestamp: int | None = None - - sync: SyncInfo | None = None - owner: Owner | None = None - - model_config = pydantic.ConfigDict(extra="allow") - - -class RegularFile(BaseFile): - """Represents a generic file.""" - - type: typing.Literal["FILE"] = "FILE" # pyrefly: ignore[bad-override] - - -class PrintFile(BaseFile): - """Represents a print file (G-code, BG-code).""" - - type: typing.Literal["PRINT_FILE"] = "PRINT_FILE" # pyrefly: ignore[bad-override] - preview_url: str | None = None - preview_mimetype: str | None = None - meta: PrintFileMeta | None = None - - -class FirmwareFile(BaseFile): - """Represents a firmware file on the printer.""" - - type: typing.Literal["FIRMWARE"] = "FIRMWARE" # pyrefly: ignore[bad-override] - printer_type: str | None = None - release_url: pydantic.HttpUrl | None = None - meta: FirmwareFileMeta | None = None - - -File = typing.Annotated[PrintFile | FirmwareFile | RegularFile, pydantic.Field(discriminator="type")] - - -class JobInfo(WarnExtraFieldsModel): - """Snapshot of a job currently on a printer.""" - - id: int | None = None - origin_id: int | None = None - path: str | None = None - state: str | None = None - progress: float | None = None - time_printing: int | None = None - time_remaining: int | None = None - display_name: str | None = None - start: datetime.datetime | None = None - end: datetime.datetime | None = None - hash: str | None = None - preview_url: str | None = None - model_weight: float | None = None - weight_remaining: float | None = None - print_height: float | None = None - total_height: float | None = None - lifetime_id: str | None = None - - -class CancelableObject(WarnExtraFieldsModel): - """Represents an object that can be cancelled during print.""" - - id: int - name: str - polygon: list[list[float]] | None = None - - -class JobFailureReason(WarnExtraFieldsModel): - """Details about a job failure.""" - - tag: list[JobFailureTag] = pydantic.Field(default_factory=list) - other: str | None = None - - -class Job(WarnExtraFieldsModel): - """A planned or history job.""" - - id: int - lifetime_id: str | None = None - printer_uuid: str | None = None - team_id: int | None = None - origin_id: int | None = None - source: str | None = None - source_info: SourceInfo | None = None - - state: str - hash: str | None = None - time_printing: int | None = None - start: int | None = None - end: int | None = None - progress: float | None = None - planned: dict | None = None - - print_height: float | None = None - - file: File | None = None - path: str | None = None - - reason: JobFailureReason | None = None - - cancelable_objects: list[CancelableObject] | None = None - - -class Temperatures(WarnExtraFieldsModel): - """Printer temperatures.""" - - temp_nozzle: float | None = None - temp_bed: float | None = None - target_nozzle: float | None = None - target_bed: float | None = None - - -class Camera(WarnExtraFieldsModel): - """Camera information.""" - - id: int | None = None # Numeric ID for snapshots - token: str | None = None # Alphanumeric token/id in some contexts? - name: str | None = None - origin: str | None = None - resolution: str | None = None - snapshot_url: str | None = None - - config: typing.Any | None = None - options: typing.Any | None = None - capabilities: typing.Any | None = None - features: typing.Any | None = None - sort_order: typing.Any | None = None - registered: typing.Any | None = None - team_id: typing.Any | None = None - printer_uuid: typing.Any | None = None - - -class TeamUser(WarnExtraFieldsModel): - """User in a team.""" - - id: int - first_name: str | None = None - last_name: str | None = None - public_name: str | None = None - avatar: str | None = None - rights_ro: bool | None = None - rights_rw: bool | None = None - rights_use: bool | None = None - - -class Team(WarnExtraFieldsModel): - """Team information.""" - - id: int - name: str - role: str | None = None - description: str | None = None - capacity: int | None = None - organization_id: uuid_pkg.UUID | None = None - prusaconnect_api_key: pydantic.SecretStr | None = None - user_count: int | None = None - users: list[TeamUser] | None = None - invitees: list[typing.Any] | None = None - - -class NetworkInfo(WarnExtraFieldsModel): - """Network configuration details.""" - - lan_ipv4: str | None = None - lan_mac: str | None = None - hostname: str | None = None - - -class FirmwareSupport(WarnExtraFieldsModel): - """Firmware version information.""" - - latest: str | None = None - current: str | None = None - release_url: str | None = None - stable: str | None = None - prerelease: str | None = None - release: str | None = None - state: str | None = None - - -class Tool(WarnExtraFieldsModel): - """Tool/Head information.""" - - material: str | None = None - temp: float | None = None - nozzle_diameter: float | None = None - fan_hotend: float | None = None - fan_print: float | None = None - mmu: dict[str, typing.Any] | None = None - hardened: bool | None = None - high_flow: bool | None = None - active: bool | None = None - - -class SlotInfo(WarnExtraFieldsModel): - """MMU Slot information.""" - - active: int | None = None - slots: dict[str, Tool] | None = None - state: str | None = None - command: str | None = None - - -class Printer(WarnExtraFieldsModel): - """Detailed Printer Object. - - Matches structure in `printers.error.response.json` and `printer_details.json`. - """ - - uuid: str | None = None # UUID might not be in the detail root, but often is - name: str | None = None - printer_state: PrinterState | None = pydantic.Field( - None, validation_alias=pydantic.AliasChoices("printer_state", "state") - ) # API uses 'state' or 'printer_state' - disabled: dict[str, bool] | None = None - printer_model: str | None = None - firmware_version: str | None = pydantic.Field(None, alias="firmware") - last_online: float | None = None - - network_info: NetworkInfo | None = None - support: FirmwareSupport | None = None - tools: dict[str, Tool] | None = None - slot: SlotInfo | None = None - location: str | None = None - team_name: str | None = None - appendix: bool | None = None - state_reason: str | None = None - time_delta: int | None = None - prusalink_api_key: pydantic.SecretStr | None = None - api_key: pydantic.SecretStr | None = None - sheet_settings: typing.Any | None = None - inaccurate_estimates: bool | None = None - enclosure: typing.Any | None = None - slots: int | None = None - mmu: dict[str, typing.Any] | None = None - supported_printer_models: list[str] | None = None - printer_type_compatible: list[str] | None = None - connect_state: str | None = None - allowed_functionalities: list[str] | None = None - decision_maker: typing.Any | None = None - printer_type: str | None = None - fw_printer_type: str | None = None - printer_type_name: str | None = None - flags: dict[str, typing.Any] | None = None - max_filename: int | None = None - printable_extension: list[str] | None = None - created: datetime.datetime | None = None - sn: str | None = None - team_id: int | None = None - is_beta: bool | None = None - filament: dict[str, typing.Any] | None = None - organization_id: uuid_pkg.UUID | None = None - rights_r: bool | None = None - rights_w: bool | None = None - rights_u: bool | None = None - prusaconnect_api_key: pydantic.SecretStr | None = None - groups: list[typing.Any] | None = None - owner: Owner | None = None - - # Nested info - telemetry: Temperatures | None = pydantic.Field(None, alias="temp") - job: JobInfo | None = pydantic.Field(None, alias="job_info") - cameras: list[Camera] | None = None - - # Capabilities - nozzle_diameter: float | None = None - speed: int | None = None - flow: int | None = None - axis_x: float | None = None - axis_y: float | None = None - axis_z: float | None = None - - model_config = pydantic.ConfigDict(extra="allow") - - -class PrinterListResponse(WarnExtraFieldsModel): - """Response model for the /printers endpoint.""" - - printers: list[Printer] - - -class UploadStatus(WarnExtraFieldsModel): - """Status of a file upload to Prusa Connect.""" - - id: int - team_id: int - name: str - size: int - hash: str | None = None - state: str - source: str | None = None diff --git a/src/prusa/connect/client/models/__init__.py b/src/prusa/connect/client/models/__init__.py new file mode 100644 index 0000000..cbbc291 --- /dev/null +++ b/src/prusa/connect/client/models/__init__.py @@ -0,0 +1,118 @@ +"""Pydantic models for Prusa Connect API responses. + +This module defines the data structures used by the client to parse +API responses into typed objects. +""" + +from .cameras import ( + Camera, + CameraConfig, + CameraNetworkInfo, + CameraOptions, + CameraResolution, +) +from .common import ( + NetworkInfo, + Owner, + SourceInfo, + SyncInfo, + WarnExtraFieldsModel, +) +from .config import AppConfig, AuthConfig +from .files import ( + BaseFile, + File, + FirmwareFile, + FirmwareFileMeta, + PrintFile, + PrintFileMeta, + RegularFile, + Storage, + UploadStatus, +) +from .jobs import ( + CancelableObject, + Job, + JobFailureReason, + JobFailureTag, + JobInfo, + JobStatus, +) +from .printers import ( + FirmwareSupport, + Printer, + PrinterCommand, + PrinterListResponse, + PrinterState, + SlotInfo, + Temperatures, + Tool, +) +from .stats import ( + JobsSuccess, + JobsSuccessSeries, + MaterialQuantity, + PlannedTasks, + PlannedTasksSeries, + PrintingNotPrinting, + PrintingNotPrintingEntry, + StatsModel, +) +from .teams import Team, TeamUser + +# ruff: noqa: RUF022 +__all__ = [ + # Cameras + "Camera", + "CameraConfig", + "CameraNetworkInfo", + "CameraOptions", + "CameraResolution", + # Common + "NetworkInfo", + "Owner", + "SourceInfo", + "SyncInfo", + "WarnExtraFieldsModel", + # Files + "BaseFile", + "File", + "FirmwareFile", + "FirmwareFileMeta", + "PrintFile", + "PrintFileMeta", + "RegularFile", + "Storage", + "UploadStatus", + # Jobs + "CancelableObject", + "Job", + "JobFailureReason", + "JobFailureTag", + "JobInfo", + "JobStatus", + # Printers + "FirmwareSupport", + "Printer", + "PrinterCommand", + "PrinterListResponse", + "PrinterState", + "SlotInfo", + "Temperatures", + "Tool", + # Stats + "JobsSuccess", + "JobsSuccessSeries", + "MaterialQuantity", + "PlannedTasks", + "PlannedTasksSeries", + "PrintingNotPrinting", + "PrintingNotPrintingEntry", + "StatsModel", + # Teams + "Team", + "TeamUser", + # Config + "AppConfig", + "AuthConfig", +] diff --git a/src/prusa/connect/client/models/cameras.py b/src/prusa/connect/client/models/cameras.py new file mode 100644 index 0000000..510fa3c --- /dev/null +++ b/src/prusa/connect/client/models/cameras.py @@ -0,0 +1,62 @@ +"""Camera models for Prusa Connect SDK.""" + +from .common import WarnExtraFieldsModel + + +class CameraResolution(WarnExtraFieldsModel): + """Camera resolution details.""" + + width: int + height: int + + +class CameraNetworkInfo(WarnExtraFieldsModel): + """Camera network configuration.""" + + wifi_mac: str | None = None + wifi_ipv4: str | None = None + wifi_ssid: str | None = None + + +class CameraConfig(WarnExtraFieldsModel): + """Camera internal configuration snapshot.""" + + name: str | None = None + path: str | None = None + model: str | None = None + driver: str | None = None + firmware: str | None = None + rotation: int | None = None + camera_id: str | None = None + resolution: CameraResolution | None = None + manufacturer: str | None = None + network_info: CameraNetworkInfo | None = None + trigger_scheme: str | None = None + + +class CameraOptions(WarnExtraFieldsModel): + """Available options/capabilities for the camera.""" + + available_resolutions: list[CameraResolution] | None = None + + +class Camera(WarnExtraFieldsModel): + """Camera information.""" + + id: int | None = None # Numeric ID for snapshots + token: str | None = None # Alphanumeric token/id in some contexts? + name: str | None = None + origin: str | None = None + resolution: str | None = None + snapshot_url: str | None = None + + config: CameraConfig | None = None + options: CameraOptions | None = None + capabilities: list[str] | None = None + features: list[str] | None = None + sort_order: int | None = None + registered: bool | None = None + team_id: int | None = None + printer_uuid: str | None = None + + snapshots: list[str] | None = None diff --git a/src/prusa/connect/client/models/common.py b/src/prusa/connect/client/models/common.py new file mode 100644 index 0000000..1a97f9a --- /dev/null +++ b/src/prusa/connect/client/models/common.py @@ -0,0 +1,71 @@ +"""Common models for Prusa Connect SDK.""" + +import datetime +import json +import typing + +import pydantic +import structlog + +from prusa.connect.client import consts + +logger = structlog.get_logger(__name__) + + +class WarnExtraFieldsModel(pydantic.BaseModel): + """Base model that logs a warning if extra fields are present.""" + + model_config = pydantic.ConfigDict(extra="allow") + + def __init__(self, **data: typing.Any): + """Initialize the model.""" + super().__init__(**data) + if self.__pydantic_extra__: + logger.warning( + f"Model {self.__class__.__name__} received unknown fields: {list(self.__pydantic_extra__.keys())}" + ) + logger.debug("Full JSON", json=json.dumps(data, default=str)) + + +class NetworkInfo(WarnExtraFieldsModel): + """Network configuration details.""" + + hostname: str | None = None + ipv4: str | None = None + ipv6: str | None = None + mac: str | None = None + wifi_ssid: str | None = None + lan_ipv4: str | None = None + lan_mac: str | None = None + + +class SourceInfo(WarnExtraFieldsModel): + """Information about the source of an action or object (e.g., user).""" + + id: int | None = None + first_name: str | None = None + last_name: str | None = None + public_name: str | None = None + avatar: str | None = None + + @pydantic.field_validator("avatar") + @classmethod + def resolve_avatar_url(cls, v: typing.Any) -> typing.Any: + """Automatically prepend MEDIA_BASE_URL if missing.""" + if isinstance(v, str) and v and not v.startswith(("http://", "https://")): + return f"{consts.MEDIA_BASE_URL}{v.lstrip('/')}" + return v + + +class Owner(SourceInfo): + """Represents the owner of a resource (same fields as SourceInfo).""" + + pass + + +class SyncInfo(WarnExtraFieldsModel): + """Synchronization details for a resource.""" + + synced_by: dict[str, typing.Any] | None = None + synced: datetime.datetime | None = None + source: str | None = None diff --git a/src/prusa/connect/client/models/config.py b/src/prusa/connect/client/models/config.py new file mode 100644 index 0000000..9bfd4a9 --- /dev/null +++ b/src/prusa/connect/client/models/config.py @@ -0,0 +1,24 @@ +"""Models for the /app/config endpoint.""" + +from prusa.connect.client.models.common import WarnExtraFieldsModel + + +class AuthConfig(WarnExtraFieldsModel): + """Authentication configuration.""" + + backends: list[str] + server_url: str + client_id: str + redirect_url: str + avatar_server_url: str + max_upload_size: int + max_snapshot_size: int + max_preview_size: int + afs_enabled: bool + afs_group_id: int + + +class AppConfig(WarnExtraFieldsModel): + """Application configuration returned by /app/config.""" + + auth: AuthConfig diff --git a/src/prusa/connect/client/models/files.py b/src/prusa/connect/client/models/files.py new file mode 100644 index 0000000..ce377da --- /dev/null +++ b/src/prusa/connect/client/models/files.py @@ -0,0 +1,129 @@ +"""File models for Prusa Connect SDK.""" + +import datetime +import typing + +import pydantic + +from .common import Owner, SyncInfo, WarnExtraFieldsModel + + +class Storage(WarnExtraFieldsModel): + """Represents a storage device on the printer.""" + + type: str + path: str + mountpoint: str | None = None + name: str + read_only: bool = False + is_sfn: bool | None = None + file_count: int | None = None + free_space: int | None = None + total_space: int | None = None + + +class PrintFileMeta(WarnExtraFieldsModel): + """Metadata associated with a print file (statistics parse from G-code).""" + + model_config = pydantic.ConfigDict(extra="allow") + + extruder_colour: str | None = None + filament_abrasive: bool | None = None + temperature: int | None = None + brim_width: int | None = None + bed_temperature: int | None = None + ironing: bool | None = None + nozzle_high_flow: bool | None = None + support_material: bool | None = None + filament_type: str | None = None + filament_cost: float | None = None + total_height: float | None = None + max_layer_z: float | None = None + filament_used_m: float | None = None + filament_used_mm: float | None = None + filament_used_g: float | None = None + filament_used_cm3: float | None = None + filament_used_mm3: float | None = None + nozzle_diameter: float | None = None + fill_density: str | None = None + printer_model: str | None = None + estimated_print_time: datetime.timedelta | None = None + estimated_printing_time_normal_mode: str | None = None + layer_height: float | None = None + producer: str | None = None + slots: list[dict[str, typing.Any]] | None = None + objects_info: dict[str, typing.Any] | None = None + + +class FirmwareFileMeta(WarnExtraFieldsModel): + """Metadata associated with a firmware file.""" + + device_type_id: str | None = None + version: str | None = None + sem_ver: str | None = None + build_no: int | None = None + bbf_version: int | None = None + printer_model: str | None = None + + +class BaseFile(WarnExtraFieldsModel): + """Common fields for all file types.""" + + type: str # Discriminator field + name: str + display_name: str | None = None + size: pydantic.ByteSize | None = None + hash: str | None = None + + team_id: int | None = None + upload_id: int | None = None + uploaded: datetime.datetime | None = None + + path: str | None = None + display_path: str | None = None + read_only: bool = False + m_timestamp: int | None = None + + sync: SyncInfo | None = None + owner: Owner | None = None + + model_config = pydantic.ConfigDict(extra="allow") + + +class RegularFile(BaseFile): + """Represents a generic file.""" + + type: typing.Literal["FILE"] = "FILE" # pyrefly: ignore[bad-override] + + +class PrintFile(BaseFile): + """Represents a print file (G-code, BG-code).""" + + type: typing.Literal["PRINT_FILE"] = "PRINT_FILE" # pyrefly: ignore[bad-override] + preview_url: str | None = None + preview_mimetype: str | None = None + meta: PrintFileMeta | None = None + + +class FirmwareFile(BaseFile): + """Represents a firmware file on the printer.""" + + type: typing.Literal["FIRMWARE"] = "FIRMWARE" # pyrefly: ignore[bad-override] + printer_type: str | None = None + release_url: pydantic.HttpUrl | None = None + meta: FirmwareFileMeta | None = None + + +File = typing.Annotated[PrintFile | FirmwareFile | RegularFile, pydantic.Field(discriminator="type")] + + +class UploadStatus(WarnExtraFieldsModel): + """Status of a file upload to Prusa Connect.""" + + id: int + team_id: int + name: str + size: int + hash: str | None = None + state: str + source: str | None = None diff --git a/src/prusa/connect/client/models/jobs.py b/src/prusa/connect/client/models/jobs.py new file mode 100644 index 0000000..118950c --- /dev/null +++ b/src/prusa/connect/client/models/jobs.py @@ -0,0 +1,103 @@ +"""Job models for Prusa Connect SDK.""" + +import datetime +from enum import StrEnum + +import pydantic +from pydantic import AliasChoices, AliasPath + +from .cameras import Camera +from .common import SourceInfo, WarnExtraFieldsModel +from .files import File +from .stats import JobStatus + + +class JobFailureTag(StrEnum): + """Enum representing reasons for job failure/cancellation.""" + + IGNORED = "IGNORED" + CLOGGED_NOZZLE = "CLOGGED_NOZZLE" + NON_ADHERENT_BED = "NON_ADHERENT_BED" + UNDER_EXTRUSION = "UNDER_EXTRUSION" + OVER_EXTRUSION = "OVER_EXTRUSION" + STRINGING_OR_OOZING = "STRINGING_OR_OOZING" + GAPS_IN_THIN_WALLS = "GAPS_IN_THIN_WALLS" + OVERHEATING = "OVERHEATING" + LAYER_SHIFTING = "LAYER_SHIFTING" + SPAGHETTI_MONSTER = "SPAGHETTI_MONSTER" + LAYER_SEPARATION = "LAYER_SEPARATION" + WARPING = "WARPING" + POOR_BRIDGING = "POOR_BRIDGING" + OTHER = "OTHER" + + +class JobInfo(WarnExtraFieldsModel): + """Snapshot of a job currently on a printer.""" + + id: int | None = None + origin_id: int | None = None + path: str | None = None + state: str | None = None + progress: float | None = None + time_printing: datetime.timedelta | None = None + time_remaining: datetime.timedelta | None = None + display_name: str | None = None + start: datetime.datetime | None = None + end: datetime.datetime | None = None + hash: str | None = None + preview_url: str | None = None + model_weight: float | None = None + weight_remaining: float | None = None + print_height: float | None = None + total_height: float | None = None + lifetime_id: str | None = None + + +class CancelableObject(WarnExtraFieldsModel): + """Represents an object that can be cancelled during print.""" + + id: int + name: str + polygon: list[list[float]] | None = None + canceled: bool = False + + +class JobFailureReason(WarnExtraFieldsModel): + """Details about a job failure.""" + + tag: list[JobFailureTag] = pydantic.Field(default_factory=list) + other: str | None = None + + +class Job(WarnExtraFieldsModel): + """A planned or history job.""" + + id: int + lifetime_id: str | None = None + printer_uuid: str | None = None + team_id: int | None = None + origin_id: int | None = None + source: str | None = None + source_info: SourceInfo | None = None + + state: JobStatus + + cameras: list[Camera] | None = None + hash: str | None = None + time_printing: int | None = None + start: int | None = None + end: int | None = None + progress: float | None = None + planned: dict | None = None + + print_height: float | None = None + + file: File | None = None + path: str | None = None + + reason: JobFailureReason | None = None + + cancelable_objects: list[CancelableObject] | None = pydantic.Field( + None, validation_alias=AliasChoices("cancelable_objects", AliasPath("cancelable", "objects")) + ) + cancelable_time: datetime.datetime | None = None diff --git a/src/prusa/connect/client/models/printers.py b/src/prusa/connect/client/models/printers.py new file mode 100644 index 0000000..6b66706 --- /dev/null +++ b/src/prusa/connect/client/models/printers.py @@ -0,0 +1,193 @@ +"""Printer models for Prusa Connect SDK.""" + +import datetime +import typing +import uuid as uuid_pkg +from enum import StrEnum + +import pydantic + +from .cameras import Camera +from .common import NetworkInfo, Owner, WarnExtraFieldsModel +from .jobs import JobInfo + + +class PrinterState(StrEnum): + """Enum representing the possible states of a printer.""" + + READY = "READY" + IDLE = "IDLE" + + BUSY = "BUSY" + MANIPULATING = "MANIPULATING" + PRINTING = "PRINTING" + + PAUSED = "PAUSED" + FINISHED = "FINISHED" + STOPPED = "STOPPED" + + ATTENTION = "ATTENTION" + ERROR = "ERROR" + OFFLINE = "OFFLINE" + + # Fallback for unknown states + UNKNOWN = "UNKNOWN" + + @classmethod + def _missing_(cls, value: object) -> typing.Any: + return cls.UNKNOWN + + +class PrinterCommand(StrEnum): + """Enum representing known commands for a printer. + + NOTE: These commands are the subset of commands on a + MK4S printer that do not require any additional parameters. + + TODO(dcode): Add support for commands that require additional parameters. + TODO(dcode): Consider dynamic command generation from the printer's capabilities. + """ + + SET_PRINTER_READY = "SET_PRINTER_READY" + CANCEL_PRINTER_READY = "CANCEL_PRINTER_READY" + PAUSE_PRINT = "PAUSE_PRINT" + RESUME_PRINT = "RESUME_PRINT" + STOP_PRINT = "STOP_PRINT" + RESET_PRINTER = "RESET_PRINTER" + UNLOAD_FILAMENT = "UNLOAD_FILAMENT" + SEND_INFO = "SEND_INFO" + STOP_TRANSFER = "STOP_TRANSFER" + SEND_STATE_INFO = "SEND_STATE_INFO" + RESET = "RESET" + DISABLE_STEPPERS = "DISABLE_STEPPERS" + BEEP = "BEEP" + # Fallback for unknown states + UNKNOWN = "UNKNOWN" + + @classmethod + def _missing_(cls, value: object) -> typing.Any: + return cls.UNKNOWN + + +class Temperatures(WarnExtraFieldsModel): + """Printer temperatures.""" + + temp_nozzle: float | None = None + temp_bed: float | None = None + target_nozzle: float | None = None + target_bed: float | None = None + + +class FirmwareSupport(WarnExtraFieldsModel): + """Firmware version information.""" + + latest: str | None = None + current: str | None = None + release_url: str | None = None + stable: str | None = None + prerelease: str | None = None + release: str | None = None + state: str | None = None + + +class Tool(WarnExtraFieldsModel): + """Tool/Head information.""" + + material: str | None = None + temp: float | None = None + nozzle_diameter: float | None = None + fan_hotend: float | None = None + fan_print: float | None = None + mmu: dict[str, typing.Any] | None = None + hardened: bool | None = None + high_flow: bool | None = None + active: bool | None = None + + +class SlotInfo(WarnExtraFieldsModel): + """MMU Slot information.""" + + active: int | None = None + slots: dict[str, Tool] | None = None + state: str | None = None + command: str | None = None + + +class Printer(WarnExtraFieldsModel): + """Detailed Printer Object. + + Matches structure in `printers.error.response.json` and `printer_details.json`. + """ + + uuid: str | None = None # UUID might not be in the detail root, but often is + name: str | None = None + printer_state: PrinterState | None = pydantic.Field( + None, validation_alias=pydantic.AliasChoices("printer_state", "state") + ) # API uses 'state' or 'printer_state' + disabled: dict[str, bool] | None = None + printer_model: str | None = None + firmware_version: str | None = pydantic.Field(None, alias="firmware") + last_online: float | None = None + + network_info: NetworkInfo | None = None + + support: FirmwareSupport | None = None + tools: dict[str, Tool] | None = None + slot: SlotInfo | None = None + location: str | None = None + team_name: str | None = None + appendix: bool | None = None + state: PrinterState | None = None + state_reason: str | None = None + time_delta: int | None = None + prusalink_api_key: pydantic.SecretStr | None = None + api_key: pydantic.SecretStr | None = None + sheet_settings: typing.Any | None = None + inaccurate_estimates: bool | None = None + enclosure: typing.Any | None = None + slots: int | None = None + mmu: dict[str, typing.Any] | None = None + supported_printer_models: list[str] | None = None + printer_type_compatible: list[str] | None = None + connect_state: str | None = None + allowed_functionalities: list[str] | None = None + decision_maker: typing.Any | None = None + printer_type: str | None = None + fw_printer_type: str | None = None + printer_type_name: str | None = None + flags: dict[str, typing.Any] | None = None + max_filename: int | None = None + printable_extension: list[str] | None = None + created: datetime.datetime | None = None + sn: str | None = None + team_id: int | None = None + is_beta: bool | None = None + filament: dict[str, typing.Any] | None = None + organization_id: uuid_pkg.UUID | None = None + rights_r: bool | None = None + rights_w: bool | None = None + rights_u: bool | None = None + prusaconnect_api_key: pydantic.SecretStr | None = None + groups: list[typing.Any] | None = None + owner: Owner | None = None + + # Nested info + telemetry: Temperatures | None = pydantic.Field(None, alias="temp") + job: JobInfo | None = pydantic.Field(None, alias="job_info") + cameras: list[Camera] | None = None + + # Capabilities + nozzle_diameter: float | None = None + speed: int | None = None + flow: int | None = None + axis_x: float | None = None + axis_y: float | None = None + axis_z: float | None = None + + model_config = pydantic.ConfigDict(extra="allow") + + +class PrinterListResponse(WarnExtraFieldsModel): + """Response model for the /printers endpoint.""" + + printers: list[Printer] diff --git a/src/prusa/connect/client/models/stats.py b/src/prusa/connect/client/models/stats.py new file mode 100644 index 0000000..6d107ef --- /dev/null +++ b/src/prusa/connect/client/models/stats.py @@ -0,0 +1,116 @@ +"""Stats models for Prusa Connect SDK.""" + +import datetime +import functools +import typing +from enum import StrEnum + +import pydantic + +from .common import WarnExtraFieldsModel + +_job_status_order_map: dict["JobStatus", int] | None = None + + +@functools.total_ordering +class JobStatus(StrEnum): + """Enum representing the status of a job.""" + + PRINTING = "PRINTING" + FINISHED = "FINISHED" + + OK = "FIN_OK" + STOPPED = "FIN_STOPPED" + ERROR = "FIN_ERROR" + UNKNOWN = "FIN_UNKNOWN" + + @classmethod + def _missing_(cls, value: object) -> typing.Any: + return cls.UNKNOWN + + @classmethod + def get_order(cls, member: "JobStatus") -> int: + """Get the index of the member in the order of declaration.""" + global _job_status_order_map + if _job_status_order_map is None: + _job_status_order_map = {m: i for i, m in enumerate(cls)} + return _job_status_order_map[member] + + def __lt__(self, other): + """Compare two JobStatus members by order of declaration.""" + if self.__class__ is other.__class__: + return self.get_order(self) < self.get_order(other) + return NotImplemented + + +class StatsModel(WarnExtraFieldsModel): + """Base model for statistics with date validation.""" + + from_time: datetime.date = pydantic.Field(..., alias="from") + to_time: datetime.date = pydantic.Field(..., alias="to") + + @pydantic.field_validator("from_time", "to_time", mode="before") + @classmethod + def _validate_date(cls, v): + if isinstance(v, (int, float)): + return datetime.datetime.fromtimestamp(v, datetime.UTC).date() + return v + + +class PrintingNotPrintingEntry(WarnExtraFieldsModel): + """Represents a single entry in printing vs not printing stats.""" + + name: str + value: int + + +class PrintingNotPrinting(StatsModel): + """Printer usage statistics: printing vs not printing.""" + + printer_name: str = pydantic.Field(..., alias="name") + printer_uuid: str = pydantic.Field(..., alias="uuid") + data: list[PrintingNotPrintingEntry] + + +class MaterialQuantity(StatsModel): + """Printer usage statistics: material quantity used.""" + + printer_name: str = pydantic.Field(..., alias="name") + printer_uuid: str = pydantic.Field(..., alias="uuid") + data: list[typing.Any] + + +class PlannedTasksSeries(WarnExtraFieldsModel): + """Series data for planned tasks.""" + + printer_uuid: str = pydantic.Field(..., alias="uuid") + printer_name: str = pydantic.Field(..., alias="name") + data: list[tuple[int, int]] + + +class PlannedTasks(StatsModel): + """Printer usage statistics: planned tasks.""" + + time_axis: list[int] = pydantic.Field( + ..., alias="xAxis", validation_alias=pydantic.AliasChoices("xAxis", "time_axis"), description="Time axis" + ) + series: PlannedTasksSeries + + +class JobsSuccessSeries(WarnExtraFieldsModel): + """Series data for job success stats.""" + + status: JobStatus = pydantic.Field(..., alias="name") + data: list[int] + + +class JobsSuccess(StatsModel): + """Printer usage statistics: job success history.""" + + date_axis: list[str] = pydantic.Field( + ..., alias="xAxis", validation_alias=pydantic.AliasChoices("xAxis", "date_axis"), description="Date axis" + ) + printer_name: str = pydantic.Field(..., alias="name") + printer_uuid: str = pydantic.Field(..., alias="uuid") + series: list[JobsSuccessSeries] + time_shift: str diff --git a/src/prusa/connect/client/models/teams.py b/src/prusa/connect/client/models/teams.py new file mode 100644 index 0000000..0cd4d53 --- /dev/null +++ b/src/prusa/connect/client/models/teams.py @@ -0,0 +1,36 @@ +"""Team models for Prusa Connect SDK.""" + +import typing +import uuid as uuid_pkg + +import pydantic + +from .common import WarnExtraFieldsModel + + +class TeamUser(WarnExtraFieldsModel): + """User in a team.""" + + id: int + first_name: str | None = None + last_name: str | None = None + public_name: str | None = None + avatar: str | None = None + rights_ro: bool | None = None + rights_rw: bool | None = None + rights_use: bool | None = None + + +class Team(WarnExtraFieldsModel): + """Team information.""" + + id: int + name: str + role: str | None = None + description: str | None = None + capacity: int | None = None + organization_id: uuid_pkg.UUID | None = None + prusaconnect_api_key: pydantic.SecretStr | None = None + user_count: int | None = None + users: list[TeamUser] | None = None + invitees: list[typing.Any] | None = None diff --git a/src/prusa/connect/client/sdk.py b/src/prusa/connect/client/sdk.py index 5bcb7fe..575f6b6 100644 --- a/src/prusa/connect/client/sdk.py +++ b/src/prusa/connect/client/sdk.py @@ -6,13 +6,12 @@ How to use the most important parts: - `PrusaConnectClient`: The core class. Instantiate it (optionally with `PrusaConnectCredentials`) to begin controlling printers. -- Look at the methods available on `PrusaConnectClient`, such as `get_printers()`, `send_command(...)`, - and `get_team_users(...)`, for an exhaustive list of actions supported. +- Access resources via the service attributes: `client.printers`, `client.teams`, `client.cameras`, + `client.files`, `client.jobs`, and `client.stats`. """ import collections.abc -import json -import time +import datetime import typing from pathlib import Path @@ -24,6 +23,14 @@ from prusa.connect.client import auth, camera, command_models, consts, exceptions, gcode, models from prusa.connect.client.__version__ import __version__ +from prusa.connect.client.services import ( + cameras, + files, + jobs, + printers, + stats, + teams, +) type PrusaCameraClient = camera.PrusaCameraClient @@ -62,10 +69,18 @@ class PrusaConnectClient: >>> from prusa.connect.client import PrusaConnectClient >>> # Assume you have a credentials object >>> client = PrusaConnectClient(credentials=my_creds) - >>> printers = client.get_printers() + >>> printers = client.printers.list_printers() ``` """ + # Service attribute annotations (instance attributes set in __init__) + printers: "printers.PrinterService" + files: "files.FileService" + teams: "teams.TeamService" + cameras: "cameras.CameraService" + jobs: "jobs.JobService" + stats: "stats.StatsService" + def __init__( self, credentials: AuthStrategy | None = None, @@ -99,8 +114,11 @@ def __init__( self._timeout = timeout self._cache_dir = Path(cache_dir) if cache_dir else None self._cache_ttl = cache_ttl + self._session = requests.Session() - self._supported_commands_cache: dict[str, list[command_models.CommandDefinition]] = {} + + # Config state + self._app_config: models.AppConfig | None = None # Configure Retries retries = Retry( @@ -109,7 +127,7 @@ def __init__( status_forcelist=[500, 502, 503, 504], allowed_methods={"GET", "POST", "PUT", "DELETE", "PATCH"}, ) - adapter = HTTPAdapter(max_retries=retries) + adapter = HTTPAdapter(max_retries=retries) # type: ignore self._session.mount("https://", adapter) self._session.mount("http://", adapter) @@ -120,6 +138,95 @@ def __init__( } ) + # Initialize Services + self.printers = printers.PrinterService(self, self._cache_dir, self._cache_ttl) + self.files = files.FileService(self) + self.teams = teams.TeamService(self) + self.cameras = cameras.CameraService(self) + self.jobs = jobs.JobService(self) + self.stats = stats.StatsService(self) + + # Initialize Config + self.get_app_config() + + def request(self, method: str, endpoint: str, **kwargs: typing.Any) -> typing.Any: + """Internal method alias for services.""" + return self._request(method, endpoint, **kwargs) + + @property + def config(self) -> models.AppConfig: + """The application configuration. Verified to be populated after init.""" + if self._app_config is None: + raise exceptions.PrusaConnectError("App config not initialized.") + return self._app_config + + def get_app_config(self, force_refresh: bool = False) -> models.AppConfig: + """Fetch and cache the application configuration from /app/config. + + Args: + force_refresh: If True, ignore cached config and fetch from server. + + Returns: + The `AppConfig` object. + + Raises: + PrusaApiError: If the request fails. + ValueError: If the server does not support the required auth method. + """ + if self._app_config and not force_refresh: + return self._app_config + + # We use a raw request here to avoid circular dependency or issues if + # authentication itself relied on this config (though currently it's a check). + # We DO NOT use self._request initially because _request might use credentials + # which might rely on config. However, currently credentials are just headers. + # But wait, /app/config is public? Or authenticated? + # The curl command `curl -s https://connect.prusa3d.com/app/config` works without auth. + # So we should use a plain requests call or _request with auth=None if supported. + # _request always injects credentials. Let's use the session but skip auth injection if possible? + # Actually _request calls `self._credentials.before_request`. + # /app/config seems public. Let's try to use _request but we might get 401 if creds are bad? + # No, if creds are bad, _request raises PrusaAuthError. + # But we really want to fetch this even if creds are bad? + # The user said "use during client initialization". + # If I use `requests.get` directly, I bypass `_request` logic (retries, logging). + # I should use `self._session`. + + url = f"{self._base_url}/app/config" + logger.debug("Fetching App Config", url=url) + + try: + # /app/config is public, so we don't strictly need headers, + # but it doesn't hurt to send them if we have them. + # However, to be safe during init (where creds might be invalid/missing if we allowed that), + # maybe we should just fetch it without auth headers first? + # Existing `_request` enforces auth. + + # Use raw session to avoid auth injection for this specific public endpoint + response = self._session.get(url, timeout=self._timeout) + response.raise_for_status() + data = response.json() + except requests.RequestException as e: + raise exceptions.PrusaNetworkError(f"Failed to fetch app config: {e}") from e + + config = models.AppConfig(**data) + + # Validate Auth Backend + if "PRUSA_AUTH" not in config.auth.backends: + # We strictly require PRUSA_AUTH for now as that's all this client speaks. + logger.warning("PRUSA_AUTH not found in supported backends", backends=config.auth.backends) + # We could raise an error, but maybe the server is just being weird and we want to try anyway? + # User said: "When authenticating, we should validate that the server offers that option" + # Since this is "init", let's log a warning. If we raise Error, we might break clients if + # the server temporarily hides it or something. + # But actually, if it's not there, our auth flow (sending Bearer token) 'should' be acceptable + # if the server still accepts it. + # "select the backend accordingly" -> implied usage of `PRUSA_AUTH`. + pass + + self._app_config = config + return config + def get_camera_client(self, camera_token: str, signaling_url: str | None = None) -> camera.PrusaCameraClient: """Returns a pre-configured PrusaCameraClient. @@ -166,27 +273,33 @@ def _request(self, method: str, endpoint: str, raw: bool = False, **kwargs: typi url = f"{self._base_url}/{endpoint.lstrip('/')}" kwargs.setdefault("timeout", self._timeout) - + response: requests.Response | None = None try: logger.debug("API Request", method=method, url=url) # Check for stream in kwargs before request is_stream = kwargs.get("stream", False) response = self._session.request(method, url, **kwargs) + for h in response.headers: + logger.info("Header", header=h, value=response.headers[h]) + # Avoid reading content if streaming body_len = "STREAM" if is_stream else len(response.content) logger.debug( "API Response", - status_code=response.status_code, + status_code=getattr(response, "status_code", None), headers=dict(response.headers), body_len=body_len, ) - if response.status_code in (401, 403): + if raw: + return response + + if getattr(response, "status_code", None) in (401, 403): raise exceptions.PrusaAuthError("Invalid or expired credentials.") - if response.status_code >= 400: + if getattr(response, "status_code", -1) >= 400: # For error responses, we might want to read content even if streaming? # Usually APIs return small JSON errors. # If we are streaming a big download and fail, we probably want the error text. @@ -200,20 +313,25 @@ def _request(self, method: str, endpoint: str, raw: bool = False, **kwargs: typi raise exceptions.PrusaApiError( message=f"Request failed: {response.reason}", - status_code=response.status_code, + status_code=getattr(response, "status_code", -1), response_body=error_text, ) - if response.status_code == 204: + if getattr(response, "status_code", -1) == 204: return None - if raw: - return response - return response.json() except requests.exceptions.RequestException as e: - logger.error("Network error", error=str(e)) + logger.error( + "Network error", + error=str(e), + url=url, + method=method, + status=getattr(response, "status_code", None), + response=getattr(response, "content", None), + headers=getattr(response, "headers", None), + ) raise exceptions.PrusaNetworkError(f"Failed to connect to Prusa Connect: {e}") from e def api_request(self, method: str, endpoint: str, **kwargs: typing.Any) -> typing.Any: @@ -239,91 +357,6 @@ def api_request(self, method: str, endpoint: str, **kwargs: typing.Any) -> typin """ return self._request(method, endpoint, **kwargs) - def get_printers(self) -> list[models.Printer]: - """Fetch all printers associated with the account. - - This method caches the result to avoid redundant network calls if `cache_dir` is configured. - The cache is updated on every successful network call. If the network call fails, - it attempts to return cached data. - - Returns: - A list of `Printer` objects. - - Usage Example: - ```python - >>> printers = client.get_printers() - >>> for printer in printers: - ... print(printer.name, printer.printer_state) - ``` - """ - cache_file = None - if self._cache_dir: - cache_file = self._cache_dir / "printers" / "list.json" - - try: - data = self._request("GET", "/printers") - - # Helper to parse data - parsed_printers = [] - if isinstance(data, dict) and "printers" in data: - parsed_printers = [models.Printer.model_validate(p) for p in data["printers"]] - elif isinstance(data, list): - parsed_printers = [models.Printer.model_validate(p) for p in data] - else: - logger.warning("Unexpected printer response format", data=data) - - # Update cache if successful - if cache_file and parsed_printers: - try: - cache_file.parent.mkdir(parents=True, exist_ok=True) - # We store the raw API response or a simplified list? - # Let's store the list of models for consistency - cache_data = {"printers": [p.model_dump(mode="json") for p in parsed_printers]} - cache_file.write_text(json.dumps(cache_data, indent=2)) - except Exception as e: - logger.warning("Failed to save printers to cache", error=str(e)) - - return parsed_printers - - except Exception as e: - # Fallback to cache - if cache_file and cache_file.exists(): - try: - # Check TTL - mtime = cache_file.stat().st_mtime - age = time.time() - mtime - if age > self._cache_ttl: - logger.warning("Cached printer list expired", age=age, ttl=self._cache_ttl) - raise exceptions.PrusaAuthError("Cache expired and network failed.") # Or just fail - - logger.info("Using cached printer list due to error", error=str(e)) - data = json.loads(cache_file.read_text()) - if isinstance(data, dict) and "printers" in data: - return [models.Printer.model_validate(p) for p in data["printers"]] - except Exception as cache_e: - logger.warning("Failed to load cached printers", error=str(cache_e)) - - # if no cache or cache failed, re-raise original error - raise e - - def get_printer(self, uuid: str) -> models.Printer: - """Fetch details for a specific printer. - - Args: - uuid: The UUID of the printer. - - Returns: - A `Printer` object containing detailed telemetry and state. - - Usage Example: - ```python - >>> printer = client.get_printer("c0ffee-uuid") - >>> print(printer.telemetry.temp_nozzle) - ``` - """ - data = self._request("GET", f"/printers/{uuid}") - return models.Printer.model_validate(data) - def get_file_list(self, team_id: int) -> list[models.File]: """Fetch files for a specific team. @@ -332,24 +365,8 @@ def get_file_list(self, team_id: int) -> list[models.File]: Returns: A list of `File` objects. - - Usage Example: - ```python - >>> files = client.get_file_list(1) - >>> for file in files: - ... print(file.name) - ``` """ - data = self._request("GET", f"/teams/{team_id}/files") - - if isinstance(data, dict) and "files" in data: - logger.debug("Fetched files for team", team_id=team_id) - files = [] - for f in data["files"]: - logger.debug("File", file=f) - files.append(pydantic.TypeAdapter(models.File).validate_python(f)) - return files - return [] + return self.files.list(team_id) def get_team_file(self, team_id: int, file_hash: str) -> models.File: """Fetch details for a specific file in a team. @@ -360,16 +377,8 @@ def get_team_file(self, team_id: int, file_hash: str) -> models.File: Returns: A `File` object containing detailed file metadata. - - Usage Example: - ```python - >>> file_info = client.get_team_file(1, "file_hash") - >>> print(file_info.name) - ``` """ - data = self._request("GET", f"/teams/{team_id}/files/{file_hash}") - logger.debug("Fetched team file", team_id=team_id, file_hash=file_hash) - return pydantic.TypeAdapter(models.File).validate_python(data) + return self.files.get(team_id, file_hash) def initiate_team_upload(self, team_id: int, destination: str, filename: str, size: int) -> models.UploadStatus: """Initiate a file upload to a team's storage. @@ -383,9 +392,7 @@ def initiate_team_upload(self, team_id: int, destination: str, filename: str, si Returns: An `UploadStatus` object containing the upload ID and state. """ - payload = {"destination": destination, "filename": filename, "size": size} - data = self._request("POST", f"/users/teams/{team_id}/uploads", json=payload) - return models.UploadStatus.model_validate(data) + return self.files.initiate_upload(team_id, destination, filename, size) def upload_team_file( self, team_id: int, upload_id: int, data: bytes, content_type: str = "application/octet-stream" @@ -398,8 +405,7 @@ def upload_team_file( data: The binary content of the file. content_type: Optional Content-Type header (e.g., 'application/x-bgcode'). """ - headers = {"Content-Type": content_type, "Upload-Size": str(len(data))} - self._request("PUT", f"/teams/{team_id}/files/raw?upload_id={upload_id}", data=data, headers=headers) + return self.files.upload_data(team_id, upload_id, data, content_type) def download_team_file(self, team_id: int, file_hash: str) -> bytes: """Download a file from a team's storage. @@ -411,59 +417,18 @@ def download_team_file(self, team_id: int, file_hash: str) -> bytes: Returns: The binary content of the file. """ - response = self._request("GET", f"/teams/{team_id}/files/{file_hash}/raw", raw=True) - return response.content + return self.files.download(team_id, file_hash) - def get_cameras(self) -> list[models.Camera]: - """Fetch all cameras. - - Returns: - A list of `Camera` objects. - - Usage Example: - ```python - >>> cameras = client.get_cameras() - >>> for cam in cameras: - ... print(cam.name) - ``` - """ - data = self._request("GET", "/cameras") - if isinstance(data, dict) and "cameras" in data: - logger.debug("Received cameras.", cameras=json.dumps(data["cameras"])) - return [models.Camera.model_validate(c) for c in data["cameras"]] - return [] - - def get_teams(self) -> list[models.Team]: - """Fetch all teams associated with the account. - - Returns: - A list of `Team` objects. - - Usage Example: - ```python - >>> teams = client.get_teams() - >>> for team in teams: - ... print(team.name) - ``` - """ - data = self._request("GET", "/users/teams") - teams: list[models.Team] = [] - if isinstance(data, list): - logger.debug("Fetched multiple teams") - teams = [models.Team.model_validate(t) for t in data] - return teams - - def get_team(self, team_id: int) -> models.Team: - """Fetch detailed information for a specific team. + def get_team_users(self, team_id: int) -> list[models.TeamUser]: + """Fetch all users associated with a team. Args: team_id: The ID of the team. Returns: - A `Team` object. + A list of `TeamUser` objects. """ - data = self._request("GET", f"/users/teams/{team_id}") - return models.Team.model_validate(data) + return self.teams.list_users(team_id) def add_team_user( self, @@ -485,14 +450,7 @@ def add_team_user( Returns: True if the user was invited successfully. """ - payload = { - "email": email, - "rights_ro": rights_ro, - "rights_use": rights_use, - "rights_rw": rights_rw, - } - self._request("POST", f"/teams/{team_id}/add-user", json=payload) - return True + return self.teams.add_user(team_id, email, rights_ro, rights_use, rights_rw) def get_team_jobs(self, team_id: int, state: list[str] | None = None, limit: int | None = None) -> list[models.Job]: """Fetch job history for a team. @@ -504,27 +462,8 @@ def get_team_jobs(self, team_id: int, state: list[str] | None = None, limit: int Returns: A list of `Job` objects. - - Usage Example: - ```python - >>> jobs = client.get_team_jobs(team_id=123, limit=5) - >>> print(f"Found {len(jobs)} jobs") - ``` """ - data = self._request("GET", f"/teams/{team_id}/jobs") - jobs: list[models.Job] = [] - if isinstance(data, dict) and "jobs" in data: - jobs = [models.Job.model_validate(j) for j in data["jobs"]] - - # Client-side filtering/limiting since API params are not fully confirmed - if state: - state_set = set(state) - jobs = [j for j in jobs if j.state in state_set] - - if limit is not None: - jobs = jobs[:limit] - - return jobs + return self.jobs.list_team_jobs(team_id, state=state, limit=limit) def get_printer_jobs( self, printer_uuid: str, state: list[str] | None = None, limit: int | None = None @@ -538,93 +477,93 @@ def get_printer_jobs( Returns: A list of `Job` objects. - - Usage Example: - ```python - >>> jobs = client.get_printer_jobs("printer-uuid", state=["FINISHED"]) - >>> if jobs: - ... print(jobs[0].state) - ``` """ - data = self._request("GET", f"/printers/{printer_uuid}/jobs") - jobs: list[models.Job] = [] - if isinstance(data, dict) and "jobs" in data: - jobs = [models.Job.model_validate(j) for j in data["jobs"]] - - # Client-side filtering/limiting - if state: - state_set = set(state) - jobs = [j for j in jobs if j.state in state_set] - - if limit is not None: - jobs = jobs[:limit] + return self.jobs.list_printer_jobs(printer_uuid, state=state, limit=limit) - return jobs - - def get_printer_queue(self, printer_uuid: str) -> list[models.Job]: + def get_printer_queue(self, printer_uuid: str, limit: int = 100, offset: int = 0) -> list[models.Job]: """Fetch the print queue for a printer. Args: printer_uuid: The printer UUID. + limit: Optional maximum number of jobs to return. + offset: Optional offset for pagination. Returns: A list of `Job` objects representing the queue. - - Usage Example: - ```python - >>> queue = client.get_printer_queue("printer-uuid") - >>> if queue: - ... print(queue[0].state) - ``` """ - data = self._request("GET", f"/printers/{printer_uuid}/queue") + return self.jobs.get_queue(printer_uuid, limit, offset) + + def get_printer_material_stats( + self, + printer_uuid: str, + from_time: datetime.date | int | None = None, + to_time: datetime.date | int | None = None, + ) -> models.MaterialQuantity: + """Fetch material quantity statistics for a printer. - # Structure from users reverse engineering: - # GET response usually: {"planned_jobs": [...] } - # POST response (adding): single object + Args: + printer_uuid: The printer UUID. + from_time: Optional start date or timestamp. + to_time: Optional end date or timestamp. - if isinstance(data, dict): - if "planned_jobs" in data: - return [models.Job.model_validate(j) for j in data["planned_jobs"]] - # Fallback for other potential keys or single object if the API is quirky - if "jobs" in data: - return [models.Job.model_validate(j) for j in data["jobs"]] - if "queue" in data: - return [models.Job.model_validate(j) for j in data["queue"]] + Returns: + A `MaterialQuantity` object. + """ + return self.stats.get_material(printer_uuid, from_time, to_time) - # If it looks like a single job (has 'id' and 'state') - if "id" in data and "state" in data: - return [models.Job.model_validate(data)] + def get_printer_usage_stats( + self, + printer_uuid: str, + from_time: datetime.date | int | None = None, + to_time: datetime.date | int | None = None, + ) -> models.PrintingNotPrinting: + """Fetch printing vs not printing statistics for a printer. - elif isinstance(data, list): - return [models.Job.model_validate(j) for j in data] + Args: + printer_uuid: The printer UUID. + from_time: Optional start date or timestamp. + to_time: Optional end date or timestamp. - # Fallback empty - return [] + Returns: + A `PrintingNotPrinting` object. + """ + return self.stats.get_usage(printer_uuid, from_time, to_time) - def send_command(self, printer_uuid: str, command: str, kwargs: dict | None = None) -> bool: - """Send a command to a printer. + def get_printer_planned_tasks_stats( + self, + printer_uuid: str, + from_time: datetime.date | int | None = None, + to_time: datetime.date | int | None = None, + ) -> models.PlannedTasks: + """Fetch planned tasks statistics for a printer. Args: printer_uuid: The printer UUID. - command: The command string (e.g., 'PAUSE_PRINT', 'MOVE_Z'). - kwargs: Optional arguments for the command. + from_time: Optional start date or timestamp. + to_time: Optional end date or timestamp. Returns: - True if the command was successfully sent. - - Usage Example: - ```python - >>> client.send_command("printer-uuid", "PAUSE_PRINT") - ``` + A `PlannedTasks` object. """ - payload: dict[str, typing.Any] = {"command": command} - if kwargs: - payload["kwargs"] = kwargs + return self.stats.get_planned_tasks(printer_uuid, from_time, to_time) - # discovery says /commands/sync is definitive - self._request("POST", f"/printers/{printer_uuid}/commands/sync", json=payload) - return True + def get_printer_jobs_success_stats( + self, + printer_uuid: str, + from_time: datetime.date | int | None = None, + to_time: datetime.date | int | None = None, + ) -> models.JobsSuccess: + """Fetch jobs success statistics for a printer. + + Args: + printer_uuid: The printer UUID. + from_time: Optional start date or timestamp. + to_time: Optional end date or timestamp. + + Returns: + A `JobsSuccess` object. + """ + return self.stats.get_jobs_success(printer_uuid, from_time, to_time) def get_supported_commands(self, printer_uuid: str) -> list[command_models.CommandDefinition]: """Fetch supported commands for a printer. @@ -638,101 +577,7 @@ def get_supported_commands(self, printer_uuid: str) -> list[command_models.Comma Returns: A list of `CommandDefinition` objects. """ - # 1. Check Memory Cache - if printer_uuid in self._supported_commands_cache: - return self._supported_commands_cache[printer_uuid] - - # 2. Check Disk Cache (if enabled) - cache_file = None - if self._cache_dir: - cache_file = self._cache_dir / "printers" / printer_uuid / "commands.json" - if cache_file.exists(): - try: - mtime = cache_file.stat().st_mtime - age = time.time() - mtime - if age <= self._cache_ttl: - logger.debug("Loading commands from cache", path=str(cache_file)) - data = json.loads(cache_file.read_text()) - response = command_models.SupportedCommandsResponse.model_validate(data) - self._supported_commands_cache[printer_uuid] = response.commands - return response.commands - else: - logger.debug("Cached commands expired", age=age, ttl=self._cache_ttl) - except Exception as e: - logger.warning("Failed to load cached commands", error=str(e)) - # Fallback to network on error - - # 3. Fetch from Network - data = self._request("GET", f"/printers/{printer_uuid}/supported-commands") - - # Parse response - response = command_models.SupportedCommandsResponse.model_validate(data) - self._supported_commands_cache[printer_uuid] = response.commands - - # 4. Save to Disk (if enabled) - if cache_file: - try: - cache_file.parent.mkdir(parents=True, exist_ok=True) - cache_file.write_text(json.dumps(data, indent=2)) - except Exception as e: - logger.warning("Failed to save commands to cache", error=str(e)) - - # 5. Check Compatibility - # We ensure core commands are present. - cmd_names = {c.command for c in response.commands} - # The user specifically mentioned STOP_PRINT as core functionality. - # We can add others later if needed. - required = {"STOP_PRINT", "PAUSE_PRINT"} - missing = required - cmd_names - - if missing: - # Gather details for failure report - report_data = { - "missing_commands": list(missing), - "supported_commands": [c.model_dump(mode="json") for c in response.commands], - "printer_details": {}, - "timestamp": time.time(), - } - - try: - # Try to fetch printer details for context - p_details = self.get_printer(printer_uuid) - p_dump = p_details.model_dump(mode="json") - - # Redact sensitive info - # serial number (uuid?), printer name, owner name, printer IP, location, team name - keys_to_redact = {"name", "location", "team_name", "uuid", "serial", "ip", "hostname", "ipv4", "mac"} - - def redact_recursive(d): - if isinstance(d, dict): - for k, v in d.items(): - if k.lower() in keys_to_redact or any( - x in k.lower() for x in ["ip", "mac", "serial", "token"] - ): - d[k] = "[REDACTED]" - else: - redact_recursive(v) - elif isinstance(d, list): - for i in d: - redact_recursive(i) - - redact_recursive(p_dump) - report_data["printer_details"] = p_dump - - except Exception as e: - logger.warning("Failed to fetch printer details for error report", error=str(e)) - report_data["printer_details"] = {"error": str(e)} - - raise exceptions.PrusaCompatibilityError( - ( - f"Printer {printer_uuid} is missing required commands: {missing}." - " This may indicate a firmware incompatibility." - ), - missing_commands=list(missing), - report_data=report_data, - ) - - return response.commands + return self.printers.get_supported_commands(printer_uuid) def execute_printer_command( self, printer_uuid: str, command: str, args: dict[str, typing.Any] | None = None @@ -782,7 +627,7 @@ def execute_printer_command( raise ValueError(f"Argument '{arg_def.name}' must be a number.") # 'object' type is too generic to validate easily here without more schema - return self.send_command(printer_uuid, command, args) + return self.printers.send_command(printer_uuid, command, args) def get_snapshot(self, camera_id: str) -> bytes: """Fetch a snapshot from a camera. @@ -801,7 +646,7 @@ def get_snapshot(self, camera_id: str) -> bytes: ``` """ # Raw response for binary data - response = self._request("GET", f"/cameras/{camera_id}/snapshots/last", raw=True) + response = self._request("GET", f"/app/cameras/{camera_id}/snapshots/last", raw=True) return response.content def trigger_snapshot(self, camera_token: str) -> bool: @@ -818,7 +663,7 @@ def trigger_snapshot(self, camera_token: str) -> bool: >>> client.trigger_snapshot("camera-token-xyz") ``` """ - self._request("POST", f"/cameras/{camera_token}/snapshots") + self._request("POST", f"/app/cameras/{camera_token}/snapshots") return True def pause_print(self, printer_uuid: str) -> bool: @@ -830,7 +675,7 @@ def pause_print(self, printer_uuid: str) -> bool: Returns: True if the command was successfully sent. """ - return self.send_command(printer_uuid, "PAUSE_PRINT") + return self.printers.send_command(printer_uuid, "PAUSE_PRINT") def resume_print(self, printer_uuid: str) -> bool: """Resume the current print. @@ -841,7 +686,7 @@ def resume_print(self, printer_uuid: str) -> bool: Returns: True if the command was successfully sent. """ - return self.send_command(printer_uuid, "RESUME_PRINT") + return self.printers.send_command(printer_uuid, "RESUME_PRINT") def stop_print(self, printer_uuid: str) -> bool: """Stop the current print. @@ -852,7 +697,7 @@ def stop_print(self, printer_uuid: str) -> bool: Returns: True if the command was successfully sent. """ - return self.send_command(printer_uuid, "STOP_PRINT") + return self.printers.send_command(printer_uuid, "STOP_PRINT") def cancel_object(self, printer_uuid: str, object_id: int) -> bool: """Cancel a specific object during print. @@ -864,7 +709,7 @@ def cancel_object(self, printer_uuid: str, object_id: int) -> bool: Returns: True if the command was successfully sent. """ - return self.send_command(printer_uuid, "CANCEL_OBJECT", {"object_id": object_id}) + return self.printers.send_command(printer_uuid, "CANCEL_OBJECT", {"object_id": object_id}) def move_axis( self, @@ -902,7 +747,7 @@ def move_axis( # MOVE usually requires at least one axis or speed? # Based on captured data, we saw: {"feedrate": 3000, "x": 131, "y": 134} - return self.send_command(printer_uuid, "MOVE", kwargs) + return self.printers.send_command(printer_uuid, "MOVE", kwargs) def flash_firmware(self, printer_uuid: str, file_path: str) -> bool: """Flash firmware from a file path on the printer/storage. @@ -914,7 +759,7 @@ def flash_firmware(self, printer_uuid: str, file_path: str) -> bool: Returns: True if the command was successfully sent. """ - return self.send_command(printer_uuid, "FLASH", {"path": file_path}) + return self.printers.send_command(printer_uuid, "FLASH", {"path": file_path}) def set_job_failure_reason( self, printer_uuid: str, job_id: int, reason: models.JobFailureTag, note: str = "" @@ -931,7 +776,7 @@ def set_job_failure_reason( True if successful. """ payload = {"reason": {"tag": [reason.value], "other": note}} - self._request("PATCH", f"/printers/{printer_uuid}/jobs/{job_id}", json=payload) + self._request("PATCH", f"/app/printers/{printer_uuid}/jobs/{job_id}", json=payload) return True def get_job(self, printer_uuid: str, job_id: int) -> models.Job: @@ -944,7 +789,7 @@ def get_job(self, printer_uuid: str, job_id: int) -> models.Job: Returns: A `Job` object. """ - data = self._request("GET", f"/printers/{printer_uuid}/jobs/{job_id}") + data = self._request("GET", f"/app/printers/{printer_uuid}/jobs/{job_id}") return models.Job.model_validate(data) def get_printer_files(self, printer_uuid: str) -> list[models.File]: @@ -956,7 +801,7 @@ def get_printer_files(self, printer_uuid: str) -> list[models.File]: Returns: A list of `File` objects. """ - data = self._request("GET", f"/printers/{printer_uuid}/files") + data = self._request("GET", f"/app/printers/{printer_uuid}/files") if isinstance(data, dict) and "files" in data: return [pydantic.TypeAdapter(models.File).validate_python(f) for f in data["files"]] return [] @@ -970,7 +815,7 @@ def get_printer_storages(self, printer_uuid: str) -> list[models.Storage]: Returns: A list of `Storage` objects. """ - data = self._request("GET", f"/printers/{printer_uuid}/storages") + data = self._request("GET", f"/app/printers/{printer_uuid}/storages") if isinstance(data, list): return [models.Storage.model_validate(s) for s in data] if isinstance(data, dict) and "storages" in data: diff --git a/src/prusa/connect/client/services/base.py b/src/prusa/connect/client/services/base.py new file mode 100644 index 0000000..072595b --- /dev/null +++ b/src/prusa/connect/client/services/base.py @@ -0,0 +1,37 @@ +"""Base Service for Prusa Connect SDK modules.""" + +import typing + + +class AbstractClient(typing.Protocol): + """Protocol for the Prusa Connect Client.""" + + def request(self, method: str, endpoint: str, **kwargs: typing.Any) -> typing.Any: + """Make an authenticated request to the API.""" + ... + + printers: typing.Any + teams: typing.Any + files: typing.Any + jobs: typing.Any + cameras: typing.Any + stats: typing.Any + + @property + def config(self) -> typing.Any: + """The application configuration.""" + ... + + _app_config: typing.Any + + def get_app_config(self) -> typing.Any: + """Fetch the application configuration.""" + ... + + +class BaseService: + """Base class for domain-specific services.""" + + def __init__(self, client: AbstractClient): + """Initialize the service.""" + self._client = client diff --git a/src/prusa/connect/client/services/cameras.py b/src/prusa/connect/client/services/cameras.py new file mode 100644 index 0000000..514f1f1 --- /dev/null +++ b/src/prusa/connect/client/services/cameras.py @@ -0,0 +1,46 @@ +"""Service for Camera operations.""" + +import json + +import structlog + +from prusa.connect.client import models +from prusa.connect.client.services.base import BaseService + +logger = structlog.get_logger(__name__) + + +class CameraService(BaseService): + """Service for managing cameras.""" + + def list(self, limit: int = 50, offset: int = 0) -> list[models.Camera]: + """Fetch all cameras. + + Args: + limit: Maximum number of teams to return. + offset: Number of teams to skip. + + Returns: + A list of `Camera` objects. + """ + params = {"limit": limit, "offset": offset} + data = self._client.request("GET", "/app/cameras", params=params) + if isinstance(data, dict) and "cameras" in data: + logger.debug("Received cameras.", cameras=json.dumps(data["cameras"], default=str)) + return [models.Camera.model_validate(c) for c in data["cameras"]] + elif isinstance(data, list): + logger.debug("Received cameras.", cameras=json.dumps(data, default=str)) + return [models.Camera.model_validate(c) for c in data] + return [] + + # Note: get_client logic requires credentials access. + # The SDK refactor plan says PrusaConnectClient should delegate. + # But PrusaConnectClient holds _credentials. + # PrusaCameraClient needs a jwt token. + # This might need to stay on PrusaConnectClient or receive the token. + # For now, I'll omit it here and keep it on the main client, + # OR pass the token generator here. + # Since `BaseService` only has `request`, it doesn't have credentials access. + # Actually, `request` implementation on usage side (PrusaConnectClient) handles auth. + # So CameraService cannot easily get the raw token unless we expose it. + # I will leave `get_camera_client` on `PrusaConnectClient` directly for now as it's a factory method. diff --git a/src/prusa/connect/client/services/files.py b/src/prusa/connect/client/services/files.py new file mode 100644 index 0000000..01d3e03 --- /dev/null +++ b/src/prusa/connect/client/services/files.py @@ -0,0 +1,99 @@ +"""Service for File operations.""" + +import pydantic +import structlog + +from prusa.connect.client import models +from prusa.connect.client.services.base import BaseService + +logger = structlog.get_logger(__name__) + + +class FileService(BaseService): + """Service for managing files.""" + + def list(self, team_id: int) -> list[models.File]: + """Fetch files for a specific team. + + Args: + team_id: The team ID to fetch files for. + + Returns: + A list of `File` objects. + """ + data = self._client.request("GET", f"/app/teams/{team_id}/files") + + if isinstance(data, dict) and "files" in data: + logger.debug("Fetched files for team", team_id=team_id) + files = [] + for f in data["files"]: + logger.debug("File", file=f) + files.append(pydantic.TypeAdapter(models.File).validate_python(f)) + return files + return [] + + def get(self, team_id: int, file_hash: str) -> models.File: + """Fetch details for a specific file in a team. + + Args: + team_id: The team ID. + file_hash: The SHA256 hash or identifier of the file. + + Returns: + A `File` object containing detailed file metadata. + """ + data = self._client.request("GET", f"/app/teams/{team_id}/files/{file_hash}") + logger.debug("Fetched team file", team_id=team_id, file_hash=file_hash) + return pydantic.TypeAdapter(models.File).validate_python(data) + + def initiate_upload(self, team_id: int, destination: str, filename: str, size: int) -> models.UploadStatus: + """Initiate a file upload to a team's storage. + + Args: + team_id: The team ID. + destination: The target folder path (e.g., 'connect/My Projects'). + filename: The name of the file to upload. + size: The file size in bytes. + + Returns: + An `UploadStatus` object containing the upload ID and state. + """ + payload = {"destination": destination, "filename": filename, "size": size} + data = self._client.request("POST", f"/app/users/teams/{team_id}/uploads", json=payload) + return models.UploadStatus.model_validate(data) + + def upload_data( + self, + team_id: int, + upload_id: int, + data: bytes, + content_type: str = "application/octet-stream", + ) -> None: + """Upload raw file data for a previously initiated upload. + + Args: + team_id: The team ID. + upload_id: The ID of the upload session. + data: The binary content of the file. + content_type: Optional Content-Type header (e.g., 'application/x-bgcode'). + """ + headers = {"Content-Type": content_type, "Upload-Size": str(len(data))} + self._client.request( + "PUT", + f"/app/teams/{team_id}/files/raw?upload_id={upload_id}", + data=data, + headers=headers, + ) + + def download(self, team_id: int, file_hash: str) -> bytes: + """Download a file from a team's storage. + + Args: + team_id: The team ID. + file_hash: The SHA256 hash (or identifier) of the file. + + Returns: + The binary content of the file. + """ + response = self._client.request("GET", f"/app/teams/{team_id}/files/{file_hash}/raw", raw=True) + return response.content diff --git a/src/prusa/connect/client/services/jobs.py b/src/prusa/connect/client/services/jobs.py new file mode 100644 index 0000000..065a838 --- /dev/null +++ b/src/prusa/connect/client/services/jobs.py @@ -0,0 +1,86 @@ +"""Service for Job operations.""" + +import structlog + +from prusa.connect.client import models +from prusa.connect.client.services.base import BaseService + +logger = structlog.get_logger(__name__) + + +class JobService(BaseService): + """Service for managing jobs.""" + + def list_team_jobs( + self, team_id: int, state: list[str] | None = None, limit: int | None = None + ) -> list[models.Job]: + """Fetch job history for a team. + + Since the API does not provide a direct endpoint for team jobs, + this method aggregates jobs from all printers in the team. + """ + printers = self._client.teams.list_printers(team_id) + all_jobs: list[models.Job] = [] + + for printer in printers: + if not printer.uuid: + continue + try: + # Fetch more than 'limit' from each printer to allow better global sort if needed, + # but for simplicity we'll just take 'limit' or default. + jobs = self.list_printer_jobs(printer.uuid, state=state, limit=limit) + all_jobs.extend(jobs) + except Exception as e: + logger.warning( + "Failed to fetch jobs for printer in team", + printer_uuid=printer.uuid, + team_id=team_id, + error=str(e), + ) + + # Sort aggregated jobs by end time (descending) + all_jobs.sort(key=lambda j: (j.end or 0, j.start or 0, j.id or 0), reverse=True) + + if limit is not None: + all_jobs = all_jobs[:limit] + + return all_jobs + + def list_printer_jobs( + self, printer_uuid: str, state: list[str] | None = None, limit: int | None = None + ) -> list[models.Job]: + """Fetch job history for a printer.""" + data = self._client.request("GET", f"/app/printers/{printer_uuid}/jobs") + jobs: list[models.Job] = [] + if isinstance(data, dict) and "jobs" in data: + jobs = [models.Job.model_validate(j) for j in data["jobs"]] + + if state: + state_set = set(state) + jobs = [j for j in jobs if j.state in state_set] + + if limit is not None: + jobs = jobs[:limit] + + return jobs + + def get_queue(self, printer_uuid: str, limit: int = 100, offset: int = 0) -> list[models.Job]: + """Fetch the print queue for a printer.""" + data = self._client.request( + "GET", f"/app/printers/{printer_uuid}/queue", params={"limit": limit, "offset": offset} + ) + + if isinstance(data, dict): + if "planned_jobs" in data: + return [models.Job.model_validate(j) for j in data["planned_jobs"]] + if "jobs" in data: + return [models.Job.model_validate(j) for j in data["jobs"]] + if "queue" in data: + return [models.Job.model_validate(j) for j in data["queue"]] + if "id" in data and "state" in data: + return [models.Job.model_validate(data)] + + elif isinstance(data, list): + return [models.Job.model_validate(j) for j in data] + + return [] diff --git a/src/prusa/connect/client/services/printers.py b/src/prusa/connect/client/services/printers.py new file mode 100644 index 0000000..44febd8 --- /dev/null +++ b/src/prusa/connect/client/services/printers.py @@ -0,0 +1,146 @@ +"""Service for Printer operations.""" + +import json +import time +import typing +from pathlib import Path + +import structlog + +from prusa.connect.client import command_models, exceptions, models +from prusa.connect.client.services.base import BaseService + +logger = structlog.get_logger(__name__) + + +class PrinterService(BaseService): + """Service for managing printers.""" + + def __init__(self, client, cache_dir: Path | None = None, cache_ttl: int = 3600): + """Initialize the printer service.""" + super().__init__(client) + self._cache_dir = cache_dir + self._cache_ttl = cache_ttl + self._supported_commands_cache: dict[str, list[command_models.CommandDefinition]] = {} + + def list_printers(self, limit: int = 100, offset: int = 0) -> list[models.Printer]: + """Fetch all printers associated with the account.""" + cache_file = None + if self._cache_dir: + cache_file = self._cache_dir / "printers" / "list.json" + + try: + params = {"limit": limit, "offset": offset} + data = self._client.request("GET", "/app/printers", params=params) + + parsed_printers = [] + if isinstance(data, dict) and "printers" in data: + parsed_printers = [models.Printer.model_validate(p) for p in data["printers"]] + elif isinstance(data, list): + parsed_printers = [models.Printer.model_validate(p) for p in data] + else: + logger.warning("Unexpected printer response format", data=data) + + if cache_file and parsed_printers: + try: + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_data = {"printers": [p.model_dump(mode="json") for p in parsed_printers]} + cache_file.write_text(json.dumps(cache_data, indent=2)) + except Exception as e: + logger.warning("Failed to save printers to cache", error=str(e)) + + return parsed_printers + + except Exception as e: + if cache_file and cache_file.exists(): + try: + mtime = cache_file.stat().st_mtime + age = time.time() - mtime + if age > self._cache_ttl: + logger.warning("Cached printer list expired", age=age, ttl=self._cache_ttl) + raise exceptions.PrusaAuthError("Cache expired and network failed.") + + logger.info("Using cached printer list due to error", error=str(e)) + data = json.loads(cache_file.read_text()) + if isinstance(data, dict) and "printers" in data: + return [models.Printer.model_validate(p) for p in data["printers"]] + except Exception as cache_e: + logger.warning("Failed to load cached printers", error=str(cache_e)) + raise e + + def get(self, uuid: str) -> models.Printer: + """Fetch details for a specific printer.""" + data = self._client.request("GET", f"/app/printers/{uuid}") + return models.Printer.model_validate(data) + + def send_command(self, uuid: str, command: str, kwargs: dict | None = None) -> bool: + """Send a command to a printer.""" + payload: dict[str, typing.Any] = {"command": command} + if kwargs: + payload["kwargs"] = kwargs + self._client.request("POST", f"/app/printers/{uuid}/commands/sync", json=payload) + return True + + def get_supported_commands(self, uuid: str) -> list[command_models.CommandDefinition]: + """Fetch supported commands for a printer.""" + if uuid in self._supported_commands_cache: + return self._supported_commands_cache[uuid] + + cache_file = None + if self._cache_dir: + cache_file = self._cache_dir / "printers" / uuid / "commands.json" + if cache_file.exists(): + try: + data = json.loads(cache_file.read_text()) + cmds = [command_models.CommandDefinition.model_validate(c) for c in data] + self._supported_commands_cache[uuid] = cmds + return cmds + except Exception as e: + logger.warning("Failed to load cached commands", error=str(e)) + + data = self._client.request("GET", f"/app/printers/{uuid}/commands") + if isinstance(data, dict): + # Try to handle varying implementations format + potential_lists = [v for v in data.values() if isinstance(v, list)] + raw_cmds = potential_lists[0] if potential_lists else [] + elif isinstance(data, list): + raw_cmds = data + else: + raw_cmds = [] + + cmds = [command_models.CommandDefinition.model_validate(c) for c in raw_cmds] + self._supported_commands_cache[uuid] = cmds + + if cache_file: + try: + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps([c.model_dump(mode="json") for c in cmds], indent=2)) + except Exception as e: + logger.warning("Failed to save commands cache", error=str(e)) + + # Compatibility Check + # We require at least STOP_PRINT to be supported to ensure safe operation + required_commands = {"STOP_PRINT"} + supported_command_names = {c.command for c in cmds} + missing_commands = required_commands - supported_command_names + + if missing_commands: + logger.error("Printer missing required commands", missing=missing_commands, uuid=uuid) + try: + printer = self.get(uuid) + printer_data = printer.model_dump(mode="json") + # Redact sensitive info + for field in ["uuid", "name", "serial", "ip", "mac"]: + if field in printer_data: + printer_data[field] = "[REDACTED]" + except Exception as e: + logger.warning("Failed to fetch printer details for error report", error=str(e)) + printer_data = {"error": "Failed to fetch details"} + + raise exceptions.PrusaCompatibilityError( + f"Printer {uuid} is missing required commands: {missing_commands}", + missing_commands=list(missing_commands), + report_data={"printer_details": printer_data}, + ) + + return cmds diff --git a/src/prusa/connect/client/services/stats.py b/src/prusa/connect/client/services/stats.py new file mode 100644 index 0000000..bec9517 --- /dev/null +++ b/src/prusa/connect/client/services/stats.py @@ -0,0 +1,98 @@ +"""Service for Statistics operations.""" + +import datetime + +import pydantic +import structlog + +from prusa.connect.client import models +from prusa.connect.client.services.base import BaseService + +logger = structlog.get_logger(__name__) + + +def _to_timestamp(val: datetime.date | int | None, end: bool = False) -> int | None: + """Helper to convert date/datetime or int to unix timestamp.""" + if val is None: + return None + if isinstance(val, datetime.datetime): + return int(val.timestamp()) + if isinstance(val, datetime.date): + return int( + datetime.datetime.combine( + val, datetime.time.min if not end else datetime.time.max, tzinfo=datetime.UTC + ).timestamp() + ) + return val + + +class StatsService(BaseService): + """Service for managing statistics.""" + + def get_material( + self, + printer_uuid: str, + from_time: datetime.date | int | None = None, + to_time: datetime.date | int | None = None, + ) -> models.MaterialQuantity: + """Fetch material quantity statistics for a printer.""" + params = {} + if from_time is not None: + params["from"] = _to_timestamp(from_time) + if to_time is not None: + params["to"] = _to_timestamp(to_time) + + data = self._client.request("GET", f"/app/stats/printers/{printer_uuid}/material_quantity", params=params) + return models.MaterialQuantity.model_validate(data) + + def get_usage( + self, + printer_uuid: str, + from_time: datetime.date | int | None = None, + to_time: datetime.date | int | None = None, + ) -> models.PrintingNotPrinting: + """Fetch printing vs not printing statistics for a printer.""" + params = {} + if from_time is not None: + params["from"] = _to_timestamp(from_time) + if to_time is not None: + params["to"] = _to_timestamp(to_time) + + data = self._client.request("GET", f"/app/stats/printers/{printer_uuid}/printing_not_printing", params=params) + return models.PrintingNotPrinting.model_validate(data) + + def get_planned_tasks( + self, + printer_uuid: str, + from_time: datetime.date | int | None = None, + to_time: datetime.date | int | None = None, + ) -> models.PlannedTasks: + """Fetch planned tasks statistics for a printer.""" + params = {} + if from_time is not None: + params["from"] = _to_timestamp(from_time) + if to_time is not None: + params["to"] = _to_timestamp(to_time) + + data = self._client.request("GET", f"/app/stats/printers/{printer_uuid}/planned_tasks", params=params) + return models.PlannedTasks.model_validate(data) + + def get_jobs_success( + self, + printer_uuid: str, + from_time: datetime.date | int | None = None, + to_time: datetime.date | int | None = None, + ) -> models.JobsSuccess: + """Fetch jobs success statistics for a printer.""" + params = {} + if from_time is not None: + params["from"] = _to_timestamp(from_time) + if to_time is not None: + params["to"] = _to_timestamp(to_time) + + data = self._client.request("GET", f"/app/stats/printers/{printer_uuid}/jobs_success", params=params) + try: + return models.JobsSuccess.model_validate(data) + except pydantic.ValidationError as e: + logger.error("Jobs success stats validation error", error=e) + raise diff --git a/src/prusa/connect/client/services/teams.py b/src/prusa/connect/client/services/teams.py new file mode 100644 index 0000000..9bc74d3 --- /dev/null +++ b/src/prusa/connect/client/services/teams.py @@ -0,0 +1,100 @@ +"""Service for Team operations.""" + +import json + +import structlog + +from prusa.connect.client import models +from prusa.connect.client.services.base import BaseService + +logger = structlog.get_logger(__name__) + + +class TeamService(BaseService): + """Service for managing teams.""" + + def list_teams(self, limit: int = 50, offset: int = 0) -> list[models.Team]: + """Fetch all teams associated with the account. + + Args: + limit: Maximum number of teams to return. + offset: Number of teams to skip. + + Returns: + A list of `Team` objects. + """ + params = {"limit": limit, "offset": offset} + data = self._client.request("GET", "/app/users/teams", params=params) + teams: list[models.Team] = [] + if isinstance(data, dict) and "teams" in data: + logger.debug("Received teams.", teams=json.dumps(data["teams"], default=str)) + teams = [models.Team.model_validate(t) for t in data["teams"]] + elif isinstance(data, list): + logger.debug("Received teams.", teams=json.dumps(data, default=str)) + teams = [models.Team.model_validate(t) for t in data] + return teams + + def get(self, team_id: int) -> models.Team: + """Fetch detailed information for a specific team. + + Args: + team_id: The ID of the team. + + Returns: + A `Team` object. + """ + data = self._client.request("GET", f"/app/users/teams/{team_id}") + return models.Team.model_validate(data) + + def list_users(self, team_id: int) -> list[models.TeamUser]: + """Fetch all users associated with a team. + + Args: + team_id: The ID of the team. + + Returns: + A list of `TeamUser` objects. + """ + team = self.get(team_id) + return team.users or [] + + def list_printers(self, team_id: int) -> list[models.Printer]: + """Fetch all printers associated with a team. + + Args: + team_id: The ID of the team. + + Returns: + A list of `Printer` objects. + """ + data = self._client.request("GET", "/app/printers", params={"team_id": team_id}) + return [models.Printer.model_validate(p) for p in data] + + def add_user( + self, + team_id: int, + email: str, + rights_ro: bool = True, + rights_use: bool = False, + rights_rw: bool = False, + ) -> bool: + """Invite a user to a team. + + Args: + team_id: The ID of the team. + email: The email address of the user to invite. + rights_ro: Grant read-only rights. + rights_use: Grant usage rights. + rights_rw: Grant read-write rights. + + Returns: + True if the user was invited successfully. + """ + payload = { + "email": email, + "rights_ro": rights_ro, + "rights_use": rights_use, + "rights_rw": rights_rw, + } + self._client.request("POST", f"/app/teams/{team_id}/add-user", json=payload) + return True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ee9c9b2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + + +def pytest_load_initial_conftests(early_config, parser, args): + """Conditionally append coverage report to GITHUB_STEP_SUMMARY. + + Only applies when running in GitHub Actions. + """ + summary_file = os.getenv("GITHUB_STEP_SUMMARY") + if ( + os.getenv("GITHUB_ACTIONS") == "true" + and summary_file + and not any(arg.startswith("--cov-report=markdown-append:") for arg in args) + ): + args.append(f"--cov-report=markdown-append:{summary_file}") + + +@pytest.fixture(autouse=True) +def mock_get_app_config(): + """Mock get_app_config to prevent network calls during tests.""" + with patch("prusa.connect.client.PrusaConnectClient.get_app_config") as mock: + mock.return_value = MagicMock() + yield mock diff --git a/tests/unit_tests/test_caching.py b/tests/unit_tests/test_caching.py index 65c55d8..1d036ad 100644 --- a/tests/unit_tests/test_caching.py +++ b/tests/unit_tests/test_caching.py @@ -1,5 +1,5 @@ import json -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -13,22 +13,29 @@ def mock_cache_dir(tmp_path): @pytest.fixture def mock_client(mock_cache_dir): - client = PrusaConnectClient(credentials=MagicMock(), base_url="http://mock", cache_dir=mock_cache_dir) - client._session = MagicMock() - return client + with patch.object(PrusaConnectClient, "get_app_config"): + client = PrusaConnectClient(credentials=MagicMock(), base_url="http://mock", cache_dir=mock_cache_dir) + client._app_config = MagicMock() + client._session = MagicMock() + return client def test_cache_miss_writes_to_disk(mock_client, mock_cache_dir): # Setup mock API response printer_uuid = "test-printer-1" - mock_data = { + # We must provide the exact structure expected by the code, OR update the assertion to match what the code produces. + # The code likely enriches the data with defaults. + # Let's match what the server returns (minimal) and what the code saves (full pydantic model dump). + + server_response = { "commands": [ {"command": "CACHE_TEST", "args": []}, {"command": "STOP_PRINT", "args": []}, {"command": "PAUSE_PRINT", "args": []}, ] } - mock_client._session.request.return_value.json.return_value = mock_data + + mock_client._session.request.return_value.json.return_value = server_response mock_client._session.request.return_value.status_code = 200 # Execute @@ -42,7 +49,9 @@ def test_cache_miss_writes_to_disk(mock_client, mock_cache_dir): assert cache_file.exists() saved_data = json.loads(cache_file.read_text()) - assert saved_data == mock_data + # The saved data will have defaults filled in by Pydantic + assert len(saved_data) == 3 + assert saved_data[0]["command"] == "CACHE_TEST" def test_cache_hit_reads_from_disk(mock_client, mock_cache_dir): @@ -51,13 +60,11 @@ def test_cache_hit_reads_from_disk(mock_client, mock_cache_dir): cache_file = mock_cache_dir / "printers" / printer_uuid / "commands.json" cache_file.parent.mkdir(parents=True, exist_ok=True) - cached_data = { - "commands": [ - {"command": "DISK_HIT", "args": []}, - {"command": "STOP_PRINT", "args": []}, - {"command": "PAUSE_PRINT", "args": []}, - ] - } + cached_data = [ + {"command": "DISK_HIT", "args": []}, + {"command": "STOP_PRINT", "args": []}, + {"command": "PAUSE_PRINT", "args": []}, + ] cache_file.write_text(json.dumps(cached_data)) # Execute @@ -71,7 +78,7 @@ def test_cache_hit_reads_from_disk(mock_client, mock_cache_dir): assert commands[0].command == "DISK_HIT" # Verify memory cache is populated - assert printer_uuid in mock_client._supported_commands_cache + assert printer_uuid in mock_client.printers._supported_commands_cache def test_corrupt_cache_falls_back_to_network(mock_client, mock_cache_dir): diff --git a/tests/unit_tests/test_caching_ttl.py b/tests/unit_tests/test_caching_ttl.py index d102f53..fd7c80f 100644 --- a/tests/unit_tests/test_caching_ttl.py +++ b/tests/unit_tests/test_caching_ttl.py @@ -1,7 +1,7 @@ import json import os import time -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -15,10 +15,17 @@ def mock_cache_dir(tmp_path): @pytest.fixture def mock_client(mock_cache_dir): - # Set short TTL for testing (1 second) - client = PrusaConnectClient(credentials=MagicMock(), base_url="http://mock", cache_dir=mock_cache_dir, cache_ttl=1) - client._session = MagicMock() - return client + with patch.object(PrusaConnectClient, "get_app_config"): + # Set short TTL for testing (1 second) + client = PrusaConnectClient( + credentials=MagicMock(), + base_url="http://mock", + cache_dir=mock_cache_dir, + cache_ttl=1, + ) + client._app_config = MagicMock() + client._session = MagicMock() + return client def test_cache_ttl_expiration_commands(mock_client, mock_cache_dir): @@ -63,13 +70,11 @@ def test_cache_ttl_hit_commands(mock_client, mock_cache_dir): cache_file = mock_cache_dir / "printers" / printer_uuid / "commands.json" cache_file.parent.mkdir(parents=True, exist_ok=True) - cached_data = { - "commands": [ - {"command": "FRESH", "args": []}, - {"command": "STOP_PRINT", "args": []}, - {"command": "PAUSE_PRINT", "args": []}, - ] - } + cached_data = [ + {"command": "FRESH", "args": []}, + {"command": "STOP_PRINT", "args": []}, + {"command": "PAUSE_PRINT", "args": []}, + ] cache_file.write_text(json.dumps(cached_data)) # Set mtime to now (fresh) @@ -98,10 +103,9 @@ def test_cache_ttl_expiration_printers(mock_client, mock_cache_dir): # Setup Network Failure mock_client._session.request.side_effect = Exception("Network Down") - # Execute - expect failure because cache is expired - # get_printers re-raises the original error if cache fails/expires + # This should trigger a network call because cache is expired with pytest.raises(Exception, match="Network Down"): - mock_client.get_printers() + mock_client.printers.list_printers() def test_cache_ttl_valid_printers_fallback(mock_client, mock_cache_dir): @@ -118,7 +122,7 @@ def test_cache_ttl_valid_printers_fallback(mock_client, mock_cache_dir): # Setup Network Failure mock_client._session.request.side_effect = Exception("Network Down") - # Execute - should return cached - printers = mock_client.get_printers() + # Execute # Should read from cache despite network error + printers = mock_client.printers.list_printers() assert len(printers) == 1 assert printers[0].name == "Cached" diff --git a/tests/unit_tests/test_camera_models.py b/tests/unit_tests/test_camera_models.py new file mode 100644 index 0000000..3fa1dca --- /dev/null +++ b/tests/unit_tests/test_camera_models.py @@ -0,0 +1,75 @@ +from prusa.connect.client.models import Camera, CameraConfig, CameraNetworkInfo, CameraOptions, CameraResolution + +SAMPLE_CAMERA_DATA = { + "id": 123456, + "name": "Buddy3D Camera", + "config": { + "name": "Buddy3D Camera", + "path": "private", + "model": "Buddy3D", + "driver": "private", + "firmware": "3.0.0", + "rotation": 0, + "camera_id": "0000012345", + "resolution": {"width": 1920, "height": 1080}, + "manufacturer": "Niceboy", + "network_info": { + "wifi_mac": "00:11:22:33:44:55", + "wifi_ipv4": "10.0.0.42", + "wifi_ssid": "Fake-IoT-WiFi", + }, + "trigger_scheme": "THIRTY_SEC", + }, + "options": {"available_resolutions": [{"width": 1920, "height": 1080}]}, + "capabilities": ["trigger_scheme"], + "features": ["SocketCom", "WiFi", "trigger_scheme"], + "sort_order": 1, + "token": "fake-camera-token-123", + "origin": "OTHER", + "registered": True, + "team_id": 31337, + "printer_uuid": "printer-uuid-abc-123", +} + + +def test_camera_model_parsing(): + """Verify that the Camera model parses correctly with nested structures.""" + camera = Camera.model_validate(SAMPLE_CAMERA_DATA) + + assert camera.id == 123456 + assert camera.name == "Buddy3D Camera" + assert camera.token == "fake-camera-token-123" + assert isinstance(camera.config, CameraConfig) + assert camera.config.firmware == "3.0.0" + assert camera.config.manufacturer == "Niceboy" + + assert isinstance(camera.config.resolution, CameraResolution) + assert camera.config.resolution.width == 1920 + assert camera.config.resolution.height == 1080 + + assert isinstance(camera.config.network_info, CameraNetworkInfo) + assert camera.config.network_info.wifi_ipv4 == "10.0.0.42" + assert camera.config.network_info.wifi_ssid == "Fake-IoT-WiFi" + + assert isinstance(camera.options, CameraOptions) + assert camera.options.available_resolutions is not None + assert len(camera.options.available_resolutions) == 1 + assert camera.options.available_resolutions[0].width == 1920 + + assert camera.capabilities == ["trigger_scheme"] + assert camera.features is not None + assert "WiFi" in camera.features + assert camera.registered is True + assert camera.team_id == 31337 + assert camera.printer_uuid == "printer-uuid-abc-123" + + +def test_camera_model_missing_fields(): + """Verify that the Camera model handles missing optional fields.""" + minimal_data = {"id": 1, "token": "abc"} + camera = Camera.model_validate(minimal_data) + assert camera.id == 1 + assert camera.token == "abc" + assert camera.config is None + assert camera.options is None + assert camera.features is None diff --git a/tests/unit_tests/test_cli_api.py b/tests/unit_tests/test_cli_api.py new file mode 100644 index 0000000..9ee7325 --- /dev/null +++ b/tests/unit_tests/test_cli_api.py @@ -0,0 +1,70 @@ +import contextlib +import io +import json +from unittest.mock import MagicMock, patch + +import pytest +import requests +import responses + +from prusa.connect.client import PrusaConnectClient +from prusa.connect.client.cli import app + + +@pytest.fixture +def mock_client(): + with patch("prusa.connect.client.cli.commands.api.common.get_client") as mock: + client = MagicMock(spec=PrusaConnectClient) + mock.return_value = client + yield client + + +def test_api_command_json(mock_client): + mock_client._request.return_value = {"status": "ok"} + + with contextlib.suppress(SystemExit): + app(["api", "/app/printers"], exit_on_error=False) + + mock_client._request.assert_called_with("GET", "/app/printers", raw=True) + + +def test_api_command_post_data(mock_client): + mock_client._request.return_value = {"status": "ok"} + + with contextlib.suppress(SystemExit): + app(["api", "/app/printers", "--method", "POST", "--data", '{"name": "new"}'], exit_on_error=False) + + mock_client._request.assert_called_with("POST", "/app/printers", raw=True, json={"name": "new"}) + + +@responses.activate +def test_api_command_output_file(mock_client, tmp_path): + out_file = tmp_path / "out.json" + + # Mock return value since we're using a mock client + mock_client._request.return_value = requests.Response() + mock_client._request.return_value.status_code = 200 + mock_client._request.return_value._content = json.dumps({"status": "ok"}).encode() + mock_client._request.return_value.headers["Content-Type"] = "application/json" + + with contextlib.suppress(SystemExit): + app(["api", "/app/printers", "--output", str(out_file)], exit_on_error=False) + + assert out_file.exists() + assert json.loads(out_file.read_text()) == {"status": "ok"} + + +@responses.activate +def test_api_command_stream(mock_client, tmp_path): + out_file = tmp_path / "stream.bin" + + # Mock return value since we're using a mock client + mock_res = requests.Response() + mock_res.status_code = 200 + mock_res.raw = io.BytesIO(b"chunk1chunk2") + mock_client._request.return_value = mock_res + + with contextlib.suppress(SystemExit): + app(["api", "/app/download", "--stream", "--output", str(out_file)], exit_on_error=False) + + assert out_file.read_bytes() == b"chunk1chunk2" diff --git a/tests/unit_tests/test_cli_auth.py b/tests/unit_tests/test_cli_auth.py new file mode 100644 index 0000000..98b713e --- /dev/null +++ b/tests/unit_tests/test_cli_auth.py @@ -0,0 +1,73 @@ +import contextlib +from unittest.mock import MagicMock, patch + +import pytest + +from prusa.connect.client.cli import app + + +@pytest.fixture +def mock_creds(): + with patch("prusa.connect.client.auth.PrusaConnectCredentials.load_default") as mock: + creds = MagicMock() + mock.return_value = creds + yield creds + + +def test_auth_login(tmp_path): + # Mocking config and Prompt + with ( + patch("prusa.connect.client.cli.commands.auth.config.settings") as s_mock, + patch("prusa.connect.client.cli.commands.auth.Prompt.ask") as p_mock, + patch("prusa.connect.client.auth.interactive_login") as login_mock, + ): + s_mock.tokens_file = tmp_path / "tokens.json" + p_mock.side_effect = ["email@e.com", "pass", "123456"] + + mock_data = MagicMock() + mock_data.dump_tokens.return_value = {"acc": "tok"} + login_mock.return_value = mock_data + + with contextlib.suppress(SystemExit): + app(["auth", "login"], exit_on_error=False) + + assert login_mock.called + assert (tmp_path / "tokens.json").exists() + + +def test_auth_show(mock_creds): + mock_creds.valid = True + mock_creds.tokens.access_token.token_id = "jti1" + mock_creds.tokens.identity_token = None + mock_creds.tokens.refresh_token = None + + with contextlib.suppress(SystemExit): + app(["auth", "show"], exit_on_error=False) + + assert mock_creds.tokens.access_token.token_id == "jti1" + + +def test_auth_clear(tmp_path): + tokens_file = tmp_path / "tokens.json" + tokens_file.write_text("{}") + + with ( + patch("prusa.connect.client.cli.commands.auth.config.settings") as s_mock, + patch("prusa.connect.client.cli.commands.auth.Confirm.ask", return_value=True), + ): + s_mock.tokens_file = tokens_file + with contextlib.suppress(SystemExit): + app(["auth", "clear"], exit_on_error=False) + assert not tokens_file.exists() + + +def test_auth_print_tokens(mock_creds): + mock_creds.valid = True + mock_creds.tokens.access_token.raw_token = "raw_acc" + mock_creds.tokens.identity_token.raw_token = "raw_id" + + with contextlib.suppress(SystemExit): + app(["auth", "print-access-token"], exit_on_error=False) + + with contextlib.suppress(SystemExit): + app(["auth", "print-identity-token"], exit_on_error=False) diff --git a/tests/unit_tests/test_cli_camera.py b/tests/unit_tests/test_cli_camera.py new file mode 100644 index 0000000..25e5dd8 --- /dev/null +++ b/tests/unit_tests/test_cli_camera.py @@ -0,0 +1,133 @@ +import contextlib +from unittest.mock import MagicMock, patch + +import pytest + +from prusa.connect.client import PrusaConnectClient, models +from prusa.connect.client.models import Camera + +cyclopts = pytest.importorskip("cyclopts") + + +from prusa.connect.client.cli import app # noqa: E402 + +SAMPLE_CAMERA_DATA = { + "id": 123456, + "name": "Buddy3D Camera", + "token": "fake-camera-token-123", + "origin": "OTHER", + "config": { + "resolution": {"width": 1920, "height": 1080}, + "firmware": "3.0.0", + "model": "Buddy3D", + }, + "printer_uuid": "printer-uuid-abc-123", +} + + +@pytest.fixture +def mock_client(): + with patch("prusa.connect.client.cli.commands.camera.common.get_client") as mock: + client = MagicMock(spec=PrusaConnectClient) + client.cameras = MagicMock() + mock.return_value = client + yield client + + +def test_cli_camera_show(mock_client): + """Verify camera show command calls get_cameras and exit gracefully.""" + camera = Camera.model_validate(SAMPLE_CAMERA_DATA) + mock_client.cameras.list.return_value = [camera] + + # Test showing by ID + with contextlib.suppress(SystemExit): + app(["camera", "show", "123456"], exit_on_error=False) + + # Test showing by Token + with contextlib.suppress(SystemExit): + app(["camera", "show", "fake-camera-token-123"], exit_on_error=False) + + # Test showing by Name + with contextlib.suppress(SystemExit): + app(["camera", "show", "Buddy3D Camera"], exit_on_error=False) + + assert mock_client.cameras.list.call_count == 3 + + +def test_cli_camera_list(mock_client): + mock_client.cameras.list.return_value = [models.Camera(id=1, name="Cam1", token="tok1")] + with contextlib.suppress(SystemExit): + app(["camera", "list"], exit_on_error=False) + # Alias + with contextlib.suppress(SystemExit): + app(["cameras"], exit_on_error=False) + assert mock_client.cameras.list.call_count == 2 + + +def test_cli_camera_snapshot(mock_client, tmp_path): + mock_client.cameras.list.return_value = [models.Camera(id=123, name="Cam1")] + mock_client.get_snapshot.return_value = b"jpegdata" + out_file = tmp_path / "snap.jpg" + with contextlib.suppress(SystemExit): + app(["camera", "snapshot", "123", "--output", str(out_file)], exit_on_error=False) + mock_client.get_snapshot.assert_called_with("123") + assert out_file.read_bytes() == b"jpegdata" + + +def test_cli_camera_trigger(mock_client): + mock_client.cameras.list.return_value = [models.Camera(id=1, token="tok1")] + mock_client.trigger_snapshot.return_value = True + with contextlib.suppress(SystemExit): + app(["camera", "trigger", "1"], exit_on_error=False) + mock_client.trigger_snapshot.assert_called_with("tok1") + + +def test_cli_camera_move(mock_client): + mock_client.cameras.list.return_value = [models.Camera(id=1, token="tok1")] + mock_cam_client = MagicMock() + mock_client.get_camera_client.return_value = mock_cam_client + with contextlib.suppress(SystemExit): + app(["camera", "move", "1", "LEFT"], exit_on_error=False) + mock_cam_client.connect.assert_called() + mock_cam_client.move.assert_called_with("LEFT", 30) + + +def test_cli_camera_adjust(mock_client): + mock_client.cameras.list.return_value = [models.Camera(id=1, token="tok1")] + mock_cam_client = MagicMock() + mock_client.get_camera_client.return_value = mock_cam_client + with contextlib.suppress(SystemExit): + app(["camera", "adjust", "1", "--brightness", "50"], exit_on_error=False) + mock_cam_client.adjust.assert_called_with(brightness=50) + + +def test_cli_camera_set_current(): + with ( + patch("prusa.connect.client.cli.commands.camera.config.save_json_config") as save_mock, + patch("prusa.connect.client.cli.commands.camera.config.settings") as s_mock, + ): + with contextlib.suppress(SystemExit): + app(["camera", "set-current", "uuid-123"], exit_on_error=False) + assert s_mock.default_camera_id == "uuid-123" + assert save_mock.called + + +def test_cli_camera_show_detailed(mock_client): + """Verify camera show --detailed command.""" + camera = Camera.model_validate(SAMPLE_CAMERA_DATA) + mock_client.cameras.list.return_value = [camera] + + with contextlib.suppress(SystemExit): + app(["camera", "show", "123456", "--detailed"], exit_on_error=False) + + assert mock_client.cameras.list.called + + +def test_cli_camera_show_not_found(mock_client): + """Verify camera show handles non-existent cameras.""" + mock_client.cameras.list.return_value = [] + + with pytest.raises(SystemExit) as e: + app(["camera", "show", "nonexistent"], exit_on_error=False) + + assert e.value.code == 1 diff --git a/tests/unit_tests/test_cli_file.py b/tests/unit_tests/test_cli_file.py new file mode 100644 index 0000000..81ac0c5 --- /dev/null +++ b/tests/unit_tests/test_cli_file.py @@ -0,0 +1,80 @@ +import contextlib +import os +from unittest.mock import MagicMock, patch + +import pytest + +from prusa.connect.client import PrusaConnectClient, models +from prusa.connect.client.cli import app +from prusa.connect.client.models import PrintFile + +SAMPLE_TEAM_FILE = {"name": "test.gcode", "type": "PRINT_FILE", "size": 2048, "hash": "hash123"} + + +@pytest.fixture +def mock_client(): + with patch("prusa.connect.client.cli.commands.file.common.get_client") as mock: + client = MagicMock(spec=PrusaConnectClient) + mock.return_value = client + yield client + + +@pytest.fixture +def mock_settings(): + with patch("prusa.connect.client.cli.commands.file.config.settings") as s_mock: + s_mock.default_team_id = 1 + yield s_mock + + +def test_file_list(mock_client, mock_settings): + mock_client.get_file_list.return_value = [PrintFile.model_validate(SAMPLE_TEAM_FILE)] + + with contextlib.suppress(SystemExit): + app(["file", "list"], exit_on_error=False) + + mock_client.get_file_list.assert_called_with(1) + + # With explicit team + with contextlib.suppress(SystemExit): + app(["file", "list", "2"], exit_on_error=False) + mock_client.get_file_list.assert_called_with(2) + + +def test_file_upload(mock_client, mock_settings, tmp_path): + local_file = tmp_path / "test.gcode" + local_file.write_text("dummy gcode") + + mock_client.initiate_team_upload.return_value = models.UploadStatus( + id=123, team_id=1, name="test.gcode", size=11, state="STARTED" + ) + + with contextlib.suppress(SystemExit): + app(["file", "upload", str(local_file), "--team-id", "1"], exit_on_error=False) + + mock_client.initiate_team_upload.assert_called_with(1, "/", "test.gcode", 11) + mock_client.upload_team_file.assert_called() + + +def test_file_download(mock_client, mock_settings, tmp_path): + os.chdir(tmp_path) + mock_client.download_team_file.return_value = b"file content" + + with contextlib.suppress(SystemExit): + app(["file", "download", "hash123", "--output", "out.gcode"], exit_on_error=False) + + mock_client.download_team_file.assert_called_with(1, "hash123") + assert (tmp_path / "out.gcode").read_bytes() == b"file content" + + +def test_file_show(mock_client, mock_settings): + mock_client.get_team_file.return_value = PrintFile.model_validate(SAMPLE_TEAM_FILE) + + # Simple show + with contextlib.suppress(SystemExit): + app(["file", "show", "hash123"], exit_on_error=False) + mock_client.get_team_file.assert_called_with(1, "hash123") + + # Detailed show + with contextlib.suppress(SystemExit): + app(["file", "show", "hash123", "--detailed"], exit_on_error=False) + mock_client.get_team_file.assert_called_with(1, "hash123") diff --git a/tests/unit_tests/test_cli_job.py b/tests/unit_tests/test_cli_job.py new file mode 100644 index 0000000..9398da9 --- /dev/null +++ b/tests/unit_tests/test_cli_job.py @@ -0,0 +1,85 @@ +import contextlib +from unittest.mock import MagicMock, patch + +import pytest + +from prusa.connect.client import PrusaConnectClient +from prusa.connect.client.cli import app +from prusa.connect.client.models import Job, Printer + +SAMPLE_JOB = { + "id": 100, + "printer_uuid": "printer-1", + "state": "FINISHED", + "progress": 100, + "start": 1672531200, + "end": 1672534800, + "file": {"name": "test.gcode", "size": 1024, "type": "PRINT_FILE"}, +} + + +@pytest.fixture +def mock_client(): + with patch("prusa.connect.client.cli.commands.job.common.get_client") as mock: + client = MagicMock(spec=PrusaConnectClient) + client.printers = MagicMock() + mock.return_value = client + yield client + + +@pytest.fixture +def mock_settings(): + with patch("prusa.connect.client.cli.commands.job.config.settings") as s_mock: + s_mock.default_printer_id = "printer-1" + yield s_mock + + +def test_job_list_printer(mock_client): + mock_client.get_printer_jobs.return_value = [Job.model_validate(SAMPLE_JOB)] + + with contextlib.suppress(SystemExit): + app(["job", "list", "--printer", "printer-1"], exit_on_error=False) + + mock_client.get_printer_jobs.assert_called() + + +def test_job_list_team(mock_client): + mock_client.get_team_jobs.return_value = [Job.model_validate(SAMPLE_JOB)] + + with contextlib.suppress(SystemExit): + app(["job", "list", "--team", "1"], exit_on_error=False) + + mock_client.get_team_jobs.assert_called() + + +def test_job_list_aggregate(mock_client): + # Mocking printers.list_printers and then get_printer_jobs for each + mock_client.printers.list_printers.return_value = [Printer.model_validate({"uuid": "p1", "name": "Pr1"})] + mock_client.get_printer_jobs.return_value = [Job.model_validate(SAMPLE_JOB)] + + with contextlib.suppress(SystemExit): + app(["job", "list"], exit_on_error=False) + + mock_client.printers.list_printers.assert_called() + mock_client.get_printer_jobs.assert_called() + + +def test_job_queued(mock_client): + mock_client.get_printer_queue.return_value = [Job.model_validate(SAMPLE_JOB)] + with contextlib.suppress(SystemExit): + app(["job", "queued", "--printer", "printer-1"], exit_on_error=False) + mock_client.get_printer_queue.assert_called_with("printer-1") + + +def test_job_show(mock_client, mock_settings): + mock_client.get_job.return_value = Job.model_validate(SAMPLE_JOB) + with contextlib.suppress(SystemExit): + app(["job", "show", "100"], exit_on_error=False) + mock_client.get_job.assert_called_with("printer-1", 100) + + +def test_job_show_missing_printer(mock_client): + with patch("prusa.connect.client.cli.commands.job.config.settings") as s_mock: + s_mock.default_printer_id = None + with contextlib.suppress(SystemExit): + app(["job", "show", "100"], exit_on_error=False) diff --git a/tests/unit_tests/test_cli_printer.py b/tests/unit_tests/test_cli_printer.py new file mode 100644 index 0000000..1c75585 --- /dev/null +++ b/tests/unit_tests/test_cli_printer.py @@ -0,0 +1,209 @@ +import contextlib +from unittest.mock import MagicMock, patch + +import pytest + +from prusa.connect.client import PrusaConnectClient, models +from prusa.connect.client.cli import app +from prusa.connect.client.models import Printer, Team + +SAMPLE_PRINTER = { + "uuid": "printer-1", + "name": "MK4-1", + "printer_state": "IDLE", + "printer_model": "MK4", + "team_name": "Team A", +} + + +@pytest.fixture +def mock_client(): + # Mock multiple common.get_client calls in different modules if necessary + with ( + patch("prusa.connect.client.cli.commands.printer.common.get_client") as p_mock, + patch("prusa.connect.client.cli.commands.file.common.get_client") as f_mock, + ): + client = MagicMock(spec=PrusaConnectClient) + client.printers = MagicMock() + client.teams = MagicMock() + p_mock.return_value = client + f_mock.return_value = client + yield client + + +@pytest.fixture +def mock_settings(): + with patch("prusa.connect.client.cli.commands.printer.config.settings") as s_mock: + s_mock.default_printer_id = "default-uuid" + yield s_mock + + +def test_printer_list(mock_client): + mock_client.printers.list_printers.return_value = [Printer.model_validate(SAMPLE_PRINTER)] + + with contextlib.suppress(SystemExit): + app(["printer", "list"], exit_on_error=False) + + # Test alias + with contextlib.suppress(SystemExit): + app(["printers"], exit_on_error=False) + + # Test pattern + with contextlib.suppress(SystemExit): + app(["printer", "list", "--pattern", "MK*"], exit_on_error=False) + + assert mock_client.printers.list_printers.call_count == 3 + + +def test_printer_pause_resume(mock_client, mock_settings): + mock_client.printers.send_command.return_value = True + + # Explicit ID + with contextlib.suppress(SystemExit): + app(["printer", "pause", "printer-1"], exit_on_error=False) + mock_client.printers.send_command.assert_called_with("printer-1", "PAUSE_PRINT") + + # Default ID + with contextlib.suppress(SystemExit): + app(["printer", "resume"], exit_on_error=False) + mock_client.printers.send_command.assert_called_with("default-uuid", "RESUME_PRINT") + + +def test_printer_stop(mock_client, mock_settings): + mock_client.stop_print.return_value = True + mock_client.printers.get.return_value = Printer.model_validate({**SAMPLE_PRINTER, "job_info": {"id": 123}}) + mock_client.set_job_failure_reason.return_value = True + + # Simple stop + with contextlib.suppress(SystemExit): + app(["printer", "stop", "printer-1"], exit_on_error=False) + mock_client.stop_print.assert_called_with("printer-1") + + # Stop with reason + with contextlib.suppress(SystemExit): + app(["printer", "stop", "printer-1", "--reason", "SPAGHETTI_MONSTER", "--note", "oops"], exit_on_error=False) + mock_client.set_job_failure_reason.assert_called_with( + "printer-1", 123, models.JobFailureTag.SPAGHETTI_MONSTER, "oops" + ) + + +def test_printer_cancel_object(mock_client, mock_settings): + mock_client.cancel_object.return_value = True + with contextlib.suppress(SystemExit): + app(["printer", "cancel-object", "1", "printer-1"], exit_on_error=False) + mock_client.cancel_object.assert_called_with("printer-1", 1) + + +def test_printer_move(mock_client, mock_settings): + mock_client.move_axis.return_value = True + with contextlib.suppress(SystemExit): + app(["printer", "move", "--x", "10", "--speed", "100"], exit_on_error=False) + mock_client.move_axis.assert_called_with("default-uuid", x=10.0, y=None, z=None, e=None, speed=100.0) + + +def test_printer_flash(mock_client, mock_settings): + mock_client.flash_firmware.return_value = True + with contextlib.suppress(SystemExit): + app(["printer", "flash", "/usb/fw.bbf", "printer-1"], exit_on_error=False) + mock_client.flash_firmware.assert_called_with("printer-1", "/usb/fw.bbf") + + +def test_printer_commands(mock_client, mock_settings): + from prusa.connect.client.command_models import CommandArgument, CommandDefinition + + cmd = CommandDefinition( + command="G28", description="Home", args=[CommandArgument(name="axes", type="string", required=False)] + ) + mock_client.get_supported_commands.return_value = [cmd] + + with contextlib.suppress(SystemExit): + app(["printer", "commands", "printer-1"], exit_on_error=False) + mock_client.get_supported_commands.assert_called_with("printer-1") + + +def test_printer_execute_command(mock_client, mock_settings): + from prusa.connect.client.command_models import CommandArgument, CommandDefinition + + cmd = CommandDefinition( + command="MOVE_Z", + args=[ + CommandArgument(name="z", type="number", required=True), + CommandArgument(name="speed", type="integer", required=False), + CommandArgument(name="active", type="boolean", required=False), + ], + ) + mock_client.get_supported_commands.return_value = [cmd] + mock_client.execute_printer_command.return_value = True + + # Using flags + with contextlib.suppress(SystemExit): + app(["printer", "command", "MOVE_Z", "--z", "10.5", "--speed", "100", "--active", "true"], exit_on_error=False) + mock_client.execute_printer_command.assert_called() + args = mock_client.execute_printer_command.call_args[0][2] + assert args["z"] == 10.5 + assert args["speed"] == 100 + assert args["active"] is True + + # Using JSON --args + with contextlib.suppress(SystemExit): + app(["printer", "command", "MOVE_Z", "--args", '{"z": 5.0}'], exit_on_error=False) + args_json = mock_client.execute_printer_command.call_args[0][2] + assert args_json["z"] == 5.0 + + +def test_printer_storages(mock_client, mock_settings): + mock_client.get_printer_storages.return_value = [ + models.Storage(name="USB", type="USB", path="/usb", free_space=1024 * 1024 * 1024) + ] + with contextlib.suppress(SystemExit): + app(["printer", "storages", "printer-1"], exit_on_error=False) + mock_client.get_printer_storages.assert_called_with("printer-1") + + +def test_printer_files_list(mock_client, mock_settings): + mock_client.get_printer_files.return_value = [models.RegularFile(name="test.txt", path="/usb/test.txt", size=1024)] + with contextlib.suppress(SystemExit): + app(["printer", "files", "list", "printer-1"], exit_on_error=False) + mock_client.get_printer_files.assert_called_with("printer-1") + + +def test_printer_files_upload_download(mock_client, mock_settings, tmp_path): + # Setup mocks for printer details and teams + mock_client.printers.get.return_value = Printer.model_validate(SAMPLE_PRINTER) + mock_client.teams.list_teams.return_value = [Team(id=1, name="Team A")] + mock_client.initiate_team_upload.return_value = models.UploadStatus( + id=99, team_id=1, name="f.gcode", size=10, state="STARTED" + ) + + local_file = tmp_path / "f.gcode" + local_file.write_text("dummy gcode") + + # Upload + with contextlib.suppress(SystemExit): + app(["printer", "files", "upload", str(local_file), "printer-1"], exit_on_error=False) + mock_client.initiate_team_upload.assert_called() + + # Download + mock_client.download_team_file.return_value = b"content" + with contextlib.suppress(SystemExit): + app(["printer", "files", "download", "hash123", "printer-1"], exit_on_error=False) + mock_client.download_team_file.assert_called_with(1, "hash123") + + +def test_set_current_printer(): + with ( + patch("prusa.connect.client.cli.commands.printer.config.save_json_config") as save_mock, + patch("prusa.connect.client.cli.commands.printer.config.settings") as s_mock, + ): + with contextlib.suppress(SystemExit): + app(["printer", "set-current", "new-u"], exit_on_error=False) + assert s_mock.default_printer_id == "new-u" + save_mock.assert_called() + + +def test_printer_missing_uuid(mock_client): + with patch("prusa.connect.client.cli.commands.printer.config.settings") as s_mock: + s_mock.default_printer_id = None + with contextlib.suppress(SystemExit): + app(["printer", "pause"], exit_on_error=False) + # Should print error and return diff --git a/tests/unit_tests/test_cli_stats.py b/tests/unit_tests/test_cli_stats.py new file mode 100644 index 0000000..ac383e3 --- /dev/null +++ b/tests/unit_tests/test_cli_stats.py @@ -0,0 +1,101 @@ +import contextlib +from unittest.mock import MagicMock, patch + +import pytest + +from prusa.connect.client import PrusaConnectClient +from prusa.connect.client.cli import app +from prusa.connect.client.models import JobsSuccess, MaterialQuantity, PlannedTasks, PrintingNotPrinting + + +@pytest.fixture +def mock_client(): + with patch("prusa.connect.client.cli.commands.stats.common.get_client") as mock: + client = MagicMock(spec=PrusaConnectClient) + mock.return_value = client + yield client + + +@pytest.fixture +def mock_settings(): + with patch("prusa.connect.client.cli.commands.stats.config.settings") as s_mock: + s_mock.default_printer_id = "uuid-123" + yield s_mock + + +def test_stats_usage(mock_client, mock_settings): + mock_client.get_printer_usage_stats.return_value = PrintingNotPrinting.model_validate( + { + "from": 1672531200, + "to": 1672617600, + "name": "MK4", + "uuid": "uuid-123", + "data": [{"name": "printing", "value": 100}], + } + ) + + with contextlib.suppress(SystemExit): + app(["stats", "usage"], exit_on_error=False) + + mock_client.get_printer_usage_stats.assert_called() + + +def test_stats_material(mock_client, mock_settings): + mock_client.get_printer_material_stats.return_value = MaterialQuantity.model_validate( + { + "from": 1672531200, + "to": 1672617600, + "name": "MK4", + "uuid": "uuid-123", + "data": [{"name": "PLA", "value": 500}], + } + ) + + with contextlib.suppress(SystemExit): + app(["stats", "material", "--days", "10"], exit_on_error=False) + + mock_client.get_printer_material_stats.assert_called() + + +def test_stats_jobs(mock_client, mock_settings): + mock_client.get_printer_jobs_success_stats.return_value = JobsSuccess.model_validate( + { + "from": 1672531200, + "to": 1672617600, + "name": "MK4", + "uuid": "uuid-123", + "xAxis": ["2023-01-01"], + "series": [{"name": "success", "data": [10]}], + "time_shift": "0", + } + ) + + with contextlib.suppress(SystemExit): + app(["stats", "jobs"], exit_on_error=False) + + mock_client.get_printer_jobs_success_stats.assert_called() + + +def test_stats_planned(mock_client, mock_settings): + mock_client.get_printer_planned_tasks_stats.return_value = PlannedTasks.model_validate( + { + "from": 1672531200, + "to": 1672617600, + "name": "MK4", + "uuid": "uuid-123", + "xAxis": [], + "series": {"uuid": "uuid-123", "name": "MK4", "data": [[10, 5]]}, + } + ) + + with contextlib.suppress(SystemExit): + app(["stats", "planned"], exit_on_error=False) + + mock_client.get_printer_planned_tasks_stats.assert_called() + + +def test_stats_missing_printer(mock_client): + with patch("prusa.connect.client.cli.commands.stats.config.settings") as s_mock: + s_mock.default_printer_id = None + with contextlib.suppress(SystemExit): + app(["stats", "usage"], exit_on_error=False) diff --git a/tests/unit_tests/test_cli_team.py b/tests/unit_tests/test_cli_team.py new file mode 100644 index 0000000..a3fb536 --- /dev/null +++ b/tests/unit_tests/test_cli_team.py @@ -0,0 +1,77 @@ +import contextlib +from unittest.mock import MagicMock, patch + +import pytest + +from prusa.connect.client import PrusaConnectClient +from prusa.connect.client.cli import app +from prusa.connect.client.models import Team + +SAMPLE_TEAM = {"id": 1, "name": "Team A", "role": "OWNER", "organization_id": "00000000-0000-0000-0000-000000000001"} + + +@pytest.fixture +def mock_client(): + with patch("prusa.connect.client.cli.commands.team.common.get_client") as mock: + client = MagicMock(spec=PrusaConnectClient) + client.teams = MagicMock() + mock.return_value = client + yield client + + +@pytest.fixture +def mock_settings(): + with patch("prusa.connect.client.cli.commands.team.config.settings") as s_mock: + s_mock.default_team_id = 1 + yield s_mock + + +def test_team_list(mock_client): + mock_client.teams.list_teams.return_value = [Team.model_validate(SAMPLE_TEAM)] + + with contextlib.suppress(SystemExit): + app(["team", "list"], exit_on_error=False) + + # Alias + with contextlib.suppress(SystemExit): + app(["teams"], exit_on_error=False) + + assert mock_client.teams.list_teams.call_count == 2 + + +def test_team_show(mock_client, mock_settings): + team_data = {**SAMPLE_TEAM, "users": [{"id": 100, "username": "u1", "email": "u1@e.com", "rights_ro": True}]} + mock_client.teams.get.return_value = Team.model_validate(team_data) + + with contextlib.suppress(SystemExit): + app(["team", "show"], exit_on_error=False) + + mock_client.teams.get.assert_called_with(1) + + +def test_team_add_user(mock_client, mock_settings): + mock_client.add_team_user.return_value = True + + with contextlib.suppress(SystemExit): + app(["team", "add-user", "test@user.com", "--rights-rw"], exit_on_error=False) + + mock_client.add_team_user.assert_called_with(1, "test@user.com", True, False, True) + + +def test_set_current_team(): + with ( + patch("prusa.connect.client.cli.commands.team.config.save_json_config") as save_mock, + patch("prusa.connect.client.cli.commands.team.config.settings") as s_mock, + ): + with contextlib.suppress(SystemExit): + app(["team", "set-current", "2"], exit_on_error=False) + assert s_mock.default_team_id == 2 + save_mock.assert_called() + + +def test_team_missing_id(mock_client): + with patch("prusa.connect.client.cli.commands.team.config.settings") as s_mock: + s_mock.default_team_id = None + with pytest.raises(SystemExit) as e: + app(["team", "show"], exit_on_error=False) + assert e.value.code == 1 diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 2fd8df2..2c70293 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -32,7 +32,7 @@ def test_get_printers_success(client): ) # 2. Call the method - printers = client.get_printers() + printers = client.printers.list_printers() # 3. Assertions assert len(printers) == 1 @@ -47,4 +47,4 @@ def test_auth_failure_raises_exception(client): responses.add(responses.GET, "https://connect.prusa3d.com/app/printers", status=401) with pytest.raises(PrusaAuthError): - client.get_printers() + client.printers.list_printers() diff --git a/tests/unit_tests/test_client_improvements.py b/tests/unit_tests/test_client_improvements.py index aa6241c..391a879 100644 --- a/tests/unit_tests/test_client_improvements.py +++ b/tests/unit_tests/test_client_improvements.py @@ -27,7 +27,7 @@ def test_default_timeout(client): mock_response.headers = {} # Mock headers as a real dict mock_request.return_value = mock_response - client.get_printers() + client.printers.list_printers() mock_request.assert_called() # Check that timeout=30.0 was passed @@ -46,7 +46,7 @@ def test_custom_timeout(): mock_response.headers = {} mock_request.return_value = mock_response - client.get_printers() + client.printers.list_printers() _args, kwargs = mock_request.call_args assert kwargs["timeout"] == 10.0 diff --git a/tests/unit_tests/test_command_execution.py b/tests/unit_tests/test_command_execution.py index 574c975..0e1129b 100644 --- a/tests/unit_tests/test_command_execution.py +++ b/tests/unit_tests/test_command_execution.py @@ -36,8 +36,8 @@ def test_get_supported_commands(mock_client): assert cmds[0].args[0].name == "distance" # Verify cache - assert "printer1" in mock_client._supported_commands_cache - assert mock_client._supported_commands_cache["printer1"] == cmds + assert "printer1" in mock_client.printers._supported_commands_cache + assert mock_client.printers._supported_commands_cache["printer1"] == cmds # Verify no second request mock_client.get_supported_commands("printer1") @@ -49,7 +49,7 @@ def test_execute_printer_command_valid(mock_client): cmd_def = CommandDefinition(command="MOVE_Z", args=[CommandArgument(name="distance", type="number", required=True)]) stop_def = CommandDefinition(command="STOP_PRINT", args=[]) pause_def = CommandDefinition(command="PAUSE_PRINT", args=[]) - mock_client._supported_commands_cache["printer1"] = [cmd_def, stop_def, pause_def] + mock_client.printers._supported_commands_cache["printer1"] = [cmd_def, stop_def, pause_def] # Execute valid mock_client._session.request.return_value.status_code = 200 @@ -58,7 +58,7 @@ def test_execute_printer_command_valid(mock_client): # Verify call mock_client._session.request.assert_called_with( "POST", - "http://mock/printers/printer1/commands/sync", + "http://mock/app/printers/printer1/commands/sync", json={"command": "MOVE_Z", "kwargs": {"distance": 10.5}}, timeout=30.0, ) @@ -66,7 +66,7 @@ def test_execute_printer_command_valid(mock_client): def test_execute_printer_command_invalid_missing_arg(mock_client): cmd_def = CommandDefinition(command="MOVE_Z", args=[CommandArgument(name="distance", type="number", required=True)]) - mock_client._supported_commands_cache["printer1"] = [ + mock_client.printers._supported_commands_cache["printer1"] = [ cmd_def, CommandDefinition(command="STOP_PRINT"), CommandDefinition(command="PAUSE_PRINT"), @@ -78,7 +78,7 @@ def test_execute_printer_command_invalid_missing_arg(mock_client): def test_execute_printer_command_invalid_type(mock_client): cmd_def = CommandDefinition(command="MOVE_Z", args=[CommandArgument(name="distance", type="number", required=True)]) - mock_client._supported_commands_cache["printer1"] = [ + mock_client.printers._supported_commands_cache["printer1"] = [ cmd_def, CommandDefinition(command="STOP_PRINT"), CommandDefinition(command="PAUSE_PRINT"), @@ -89,7 +89,7 @@ def test_execute_printer_command_invalid_type(mock_client): def test_execute_printer_command_unsupported(mock_client): - mock_client._supported_commands_cache["printer1"] = [] + mock_client.printers._supported_commands_cache["printer1"] = [] with pytest.raises(ValueError, match="not supported"): mock_client.execute_printer_command("printer1", "UNKNOWN_CMD") diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py new file mode 100644 index 0000000..8437895 --- /dev/null +++ b/tests/unit_tests/test_config.py @@ -0,0 +1,108 @@ +"""Tests for App Config loading and validation.""" + +import typing +from unittest import mock + +import pytest +import requests + +from prusa.connect.client import PrusaConnectClient, consts, exceptions + + +@pytest.fixture +def mock_config_response() -> dict[str, typing.Any]: + return { + "auth": { + "backends": ["PRUSA_AUTH"], + "server_url": "https://account.prusa3d.com", + "client_id": "client-id", + "redirect_url": "https://callback", + "avatar_server_url": "https://avatars", + "max_upload_size": 1000, + "max_snapshot_size": 1000, + "max_preview_size": 1000, + "afs_enabled": False, + "afs_group_id": 0, + } + } + + +@pytest.fixture +def mock_get_app_config(): + """Override global fixture to enable real get_app_config logic.""" + yield + + +def test_init_fetches_config(mock_config_response): + """Test that initialization fetches and parses config.""" + with ( + mock.patch("requests.Session.get") as mock_get, + mock.patch( + "prusa.connect.client.auth.PrusaConnectCredentials.load_default", + return_value=mock.Mock(), + ), + ): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = mock_config_response + + client = PrusaConnectClient(base_url="https://test.connect") + + assert client.config.auth.backends == ["PRUSA_AUTH"] + assert client.config.auth.max_upload_size == 1000 + + # Verify URL + mock_get.assert_called_with("https://test.connect/app/config", timeout=consts.DEFAULT_TIMEOUT) + + +def test_init_config_network_error(): + """Test that network error during config fetch raises PrusaNetworkError.""" + with ( + mock.patch("requests.Session.get", side_effect=requests.ConnectionError("Boom")), + mock.patch( + "prusa.connect.client.auth.PrusaConnectCredentials.load_default", + return_value=mock.Mock(), + ), + pytest.raises(exceptions.PrusaNetworkError, match="Failed to fetch app config"), + ): + PrusaConnectClient() + + +def test_init_config_warning_on_missing_auth(mock_config_response): + """Test that a warning is logged if PRUSA_AUTH is missing.""" + from structlog.testing import capture_logs + + mock_config_response["auth"]["backends"] = ["OTHER_AUTH"] + + with ( + mock.patch("requests.Session.get") as mock_get, + mock.patch( + "prusa.connect.client.auth.PrusaConnectCredentials.load_default", + return_value=mock.Mock(), + ), + capture_logs() as cap_logs, + ): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = mock_config_response + + client = PrusaConnectClient() + + found_warning = False + for log in cap_logs: + if log.get("event") == "PRUSA_AUTH not found in supported backends": + found_warning = True + assert log.get("backends") == ["OTHER_AUTH"] + break + + assert found_warning, "Expected warning not found in logs" + + assert client.config.auth.backends == ["OTHER_AUTH"] + + +def test_lazy_access_error(): + """Test accessing config fails if not initialized (though init forces it now).""" + # Create client but bypass init via __new__ to simulate uninitialized state + client = PrusaConnectClient.__new__(PrusaConnectClient) + client._app_config = None + + with pytest.raises(exceptions.PrusaConnectError, match="App config not initialized"): + _ = client.config diff --git a/tests/unit_tests/test_global_flags.py b/tests/unit_tests/test_global_flags.py index fd9e679..8ffe652 100644 --- a/tests/unit_tests/test_global_flags.py +++ b/tests/unit_tests/test_global_flags.py @@ -22,9 +22,11 @@ ) def test_logging_levels(args, expected_level): # We patch structlog.make_filtering_bound_logger to capture the level passed - with patch("prusa.connect.client.cli.common.structlog.make_filtering_bound_logger") as mock_maker: - with contextlib.suppress(SystemExit): - main(args) + with ( + patch("prusa.connect.client.cli.common.structlog.make_filtering_bound_logger") as mock_maker, + contextlib.suppress(SystemExit), + ): + main(args) # We expect at least one call to configure logging with expected level # If args have flags, main calls it. diff --git a/tests/unit_tests/test_job_features.py b/tests/unit_tests/test_job_features.py index 63acdaf..03f667e 100644 --- a/tests/unit_tests/test_job_features.py +++ b/tests/unit_tests/test_job_features.py @@ -15,6 +15,7 @@ def mock_client(): with patch("prusa.connect.client.cli.commands.job.common.get_client") as mock: client = MagicMock(spec=PrusaConnectClient) + client.printers = MagicMock() mock.return_value = client yield client @@ -29,7 +30,7 @@ def test_get_printers_caching(tmp_path): with patch.object(client, "_request", return_value=mock_response) as mock_req: # 1. First call - should hit API - printers = client.get_printers() + printers = client.printers.list_printers() assert len(printers) == 1 assert printers[0].uuid == "uuid1" assert mock_req.call_count == 1 @@ -40,7 +41,7 @@ def test_get_printers_caching(tmp_path): # 2. Second call (with API failure) - should use cache mock_req.side_effect = Exception("API Down") - printers_cached = client.get_printers() + printers_cached = client.printers.list_printers() assert len(printers_cached) == 1 assert printers_cached[0].uuid == "uuid1" @@ -71,7 +72,10 @@ def test_job_filtering(): def test_cli_job_list_aggregation(mock_client): # Setup mocks - mock_client.get_printers.return_value = [Printer(uuid="p1", name="Printer 1"), Printer(uuid="p2", name="Printer 2")] + mock_client.printers.list_printers.return_value = [ + Printer(uuid="p1", name="Printer 1"), + Printer(uuid="p2", name="Printer 2"), + ] mock_client.get_printer_jobs.side_effect = [ [Job(id=1, state="FINISHED", end=100, file=PrintFile(name="j1", path="p"))], # p1 @@ -89,14 +93,14 @@ def test_cli_job_list_aggregation(mock_client): app(["job", "list"], exit_on_error=False) # Verify aggregation - assert mock_client.get_printers.called + assert mock_client.printers.list_printers.called assert mock_client.get_printer_jobs.call_count == 2 mock_client.get_printer_jobs.assert_any_call("p1", state=None, limit=None) mock_client.get_printer_jobs.assert_any_call("p2", state=None, limit=None) def test_cli_job_queued(mock_client): - mock_client.get_printers.return_value = [Printer(uuid="p1", name="Printer 1")] + mock_client.printers.list_printers.return_value = [Printer(uuid="p1", name="Printer 1")] # Mock return of get_printer_queue calling the API internally? # Actually we mock the client method, so we should test the client method separately @@ -109,7 +113,7 @@ def test_cli_job_queued(mock_client): with contextlib.suppress(SystemExit): app(["job", "queued"], exit_on_error=False) - assert mock_client.get_printers.called + assert mock_client.printers.list_printers.called mock_client.get_printer_queue.assert_called_with("p1") diff --git a/tests/unit_tests/test_printer_details.py b/tests/unit_tests/test_printer_details.py index e1a70b0..6bc6fec 100644 --- a/tests/unit_tests/test_printer_details.py +++ b/tests/unit_tests/test_printer_details.py @@ -93,6 +93,7 @@ def test_printer_model_parsing(): def mock_client(): with patch("prusa.connect.client.cli.commands.printer.common.get_client") as mock: client = MagicMock(spec=PrusaConnectClient) + client.printers = MagicMock() mock.return_value = client yield client @@ -100,22 +101,22 @@ def mock_client(): def test_cli_printer_show(mock_client): """Verify printer show command runs and likely outputs some of our new fields.""" printer = Printer.model_validate(SAMPLE_PRINTER_DETAILS) - mock_client.get_printer.return_value = printer + mock_client.printers.get.return_value = printer # Run simple show with contextlib.suppress(SystemExit): app(["printer", "show", "uuid"], exit_on_error=False) - mock_client.get_printer.assert_called_with("uuid") + mock_client.printers.get.assert_called_with("uuid") def test_cli_printer_show_detailed(mock_client): """Verify printer show --detailed command.""" printer = Printer.model_validate(SAMPLE_PRINTER_DETAILS) - mock_client.get_printer.return_value = printer + mock_client.printers.get.return_value = printer # Run detailed show with contextlib.suppress(SystemExit): app(["printer", "show", "uuid", "--detailed"], exit_on_error=False) - mock_client.get_printer.assert_called_with("uuid") + mock_client.printers.get.assert_called_with("uuid") diff --git a/tests/unit_tests/test_retry.py b/tests/unit_tests/test_retry.py index 20b087b..6cb480d 100644 --- a/tests/unit_tests/test_retry.py +++ b/tests/unit_tests/test_retry.py @@ -53,6 +53,6 @@ def test_retry_on_final_failure(mock_send, client): mock_send.side_effect = RetryError("Max retries exceeded") with pytest.raises(PrusaNetworkError) as exc: - client.get_printers() + client.printers.list_printers() assert "Failed to connect" in str(exc.value) diff --git a/tests/unit_tests/test_sdk_coverage.py b/tests/unit_tests/test_sdk_coverage.py new file mode 100644 index 0000000..0948b98 --- /dev/null +++ b/tests/unit_tests/test_sdk_coverage.py @@ -0,0 +1,512 @@ +import datetime +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest +import requests +import responses + +from prusa.connect.client import PrusaConnectClient, auth, exceptions, models +from prusa.connect.client.services.stats import _to_timestamp + + +class MockCredentials: + def before_request(self, headers): + headers["Authorization"] = "Bearer mock_token" + + +@pytest.fixture +def client(): + with patch("prusa.connect.client.PrusaConnectClient.get_app_config"): + c = PrusaConnectClient(credentials=MockCredentials()) + c._app_config = MagicMock() + + # Disable retries for testing + from requests.adapters import HTTPAdapter + + adapter = HTTPAdapter(max_retries=0) + c._session.mount("https://", adapter) + c._session.mount("http://", adapter) + return c + + +def test_to_timestamp(): + # Test None + assert _to_timestamp(None) is None + + # Test int + assert _to_timestamp(123456) == 123456 + + # Test datetime + dt = datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.UTC) + assert _to_timestamp(dt) == int(dt.timestamp()) + + # Test date start of day + d = datetime.date(2023, 1, 1) + expected_start = int(datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.UTC).timestamp()) + assert _to_timestamp(d) == expected_start + + # Test date end of day + expected_end = int(datetime.datetime(2023, 1, 1, 23, 59, 59, 999999, tzinfo=datetime.UTC).timestamp()) + assert _to_timestamp(d, end=True) == expected_end + + +@responses.activate +def test_get_camera_client(client): + # Mocking PrusaConnectCredentials for the access token extraction logic + class MockPrusaCredentials(auth.PrusaConnectCredentials): + def __init__(self): + class MockToken: + raw_token = "raw_jwt_token" + + class MockTokens: + access_token = MockToken() + + self.tokens = MockTokens() # type: ignore + + def before_request(self, headers): + pass + + client_with_creds = PrusaConnectClient(credentials=MockPrusaCredentials()) + cam = client_with_creds.get_camera_client("cam123") + assert cam.camera_token == "cam123" + assert cam.jwt_token == "raw_jwt_token" + + # Test with signaling_url override + cam2 = client_with_creds.get_camera_client("cam456", signaling_url="https://signaling.example.com") + assert cam2.camera_token == "cam456" + + # Test with non-PrusaConnectCredentials (no JWT) + client_no_jwt = PrusaConnectClient(credentials=MockCredentials()) + cam3 = client_no_jwt.get_camera_client("cam789") + assert cam3.camera_token == "cam789" + assert cam3.jwt_token is None + + +@responses.activate +def test_request_error_body_reading(client): + responses.add(responses.GET, "https://connect.prusa3d.com/app/error", status=500, body="Critical Error Details") + + with pytest.raises(exceptions.PrusaApiError) as excinfo: + client.api_request("GET", "/app/error") + + assert "Critical Error Details" in str(excinfo.value.response_body) + + # Test failure to read error body + responses.add(responses.GET, "https://connect.prusa3d.com/app/error-bad", status=500) + with MagicMock(spec=requests.Response) as mock_resp: + mock_resp.status_code = 500 + mock_resp.reason = "Internal Error" + type(mock_resp).text = PropertyMock(side_effect=Exception("Failed to decode")) + with MagicMock() as mock_session: + mock_session.request.return_value = mock_resp + client._session = mock_session + with pytest.raises(exceptions.PrusaApiError) as excinfo2: + client.api_request("GET", "/app/error-bad") + assert "" in str(excinfo2.value.response_body) + + +@responses.activate +def test_get_cameras(client): + # Test dict response + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/cameras", + json={"cameras": [{"id": 1, "name": "Camera 1"}]}, + status=200, + ) + cameras = client.cameras.list() + assert len(cameras) == 1 + assert cameras[0].name == "Camera 1" + + # Test list response + responses.add( + responses.GET, "https://connect.prusa3d.com/app/cameras-list", json=[{"id": 2, "name": "Camera 2"}], status=200 + ) + # We need to manually call it or change the mock if we want to hit the branch + # But cameras.list uses "/cameras" + responses.replace( + responses.GET, "https://connect.prusa3d.com/app/cameras", json=[{"id": 2, "name": "Camera 2"}], status=200 + ) + cameras2 = client.cameras.list() + assert len(cameras2) == 1 + assert cameras2[0].name == "Camera 2" + + # Test empty/unexpected response + responses.replace(responses.GET, "https://connect.prusa3d.com/app/cameras", json={"something_else": []}, status=200) + assert client.cameras.list() == [] + + +@responses.activate +def test_get_teams_and_users(client): + responses.add( + responses.GET, "https://connect.prusa3d.com/app/users/teams", json=[{"id": 1, "name": "Team A"}], status=200 + ) + teams = client.teams.list_teams() + assert len(teams) == 1 + assert teams[0].name == "Team A" + + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/users/teams/1", + json={ + "id": 1, + "name": "Team A", + "users": [ + { + "id": 10, + "username": "user1", + "email": "u1@e.com", + "rights_ro": True, + "rights_use": True, + "rights_rw": True, + } + ], + }, + status=200, + ) + team = client.teams.get(1) + assert team.name == "Team A" + + users = client.get_team_users(1) + assert len(users) == 1 + assert users[0].username == "user1" + + +@responses.activate +def test_add_team_user(client): + responses.add(responses.POST, "https://connect.prusa3d.com/app/teams/1/add-user", status=204) + assert client.add_team_user(1, "new@user.com", rights_rw=True) is True + + +@responses.activate +def test_job_management(client): + # 2. Team Jobs (Aggregation mode) + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers?team_id=1", + json=[{"uuid": "printer-1", "name": "MK4-1", "team_id": 1}], + status=200, + ) + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/printer-1/jobs", + json={"jobs": [{"id": 1690023, "state": "FIN_OK"}]}, + status=200, + ) + jobs = client.get_team_jobs(1) + assert len(jobs) == 1 + assert jobs[0].id == 1690023 + assert jobs[0].state == "FIN_OK" + + # state filter + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/printer-1/jobs", + json={"jobs": [{"id": 100, "state": "FINISHED"}]}, + status=200, + ) + jobs = client.get_team_jobs(1, state=["FINISHED"]) + assert len(jobs) == 1 + assert jobs[0].id == 100 + assert jobs[0].state == "FINISHED" + + # limit filter + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/printer-1/jobs", + json={"jobs": [{"id": 101, "state": "FINISHED"}]}, + status=200, + ) + jobs = client.get_team_jobs(1, limit=5) + assert len(jobs) == 1 + assert jobs[0].id == 101 + + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/printer-uuid/jobs/100", + json={"id": 100, "state": "FINISHED"}, + status=200, + ) + job = client.get_job("printer-uuid", 100) + assert job.id == 100 + + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/printer-uuid/jobs", + json={"jobs": [{"id": 101, "state": "FINISHED"}]}, + status=200, + ) + jobs = client.get_printer_jobs("printer-uuid", state=["FINISHED"], limit=1) + assert len(jobs) == 1 + + +@responses.activate +def test_status_and_control(client): + # send_command + responses.add(responses.POST, "https://connect.prusa3d.com/app/printers/uuid/commands/sync", status=204) + assert client.pause_print("uuid") is True + assert client.resume_print("uuid") is True + assert client.stop_print("uuid") is True + assert client.cancel_object("uuid", 1) is True + assert client.move_axis("uuid", x=10, speed=100) is True + assert client.flash_firmware("uuid", "/usb/fw.bbf") is True + + # statistics + common_stats = {"from": 1672531200, "to": 1672617600, "name": "MK4", "uuid": "uuid"} + + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/stats/printers/uuid/material_quantity", + json={**common_stats, "data": []}, + status=200, + ) + stats = client.get_printer_material_stats("uuid", from_time=datetime.date(2023, 1, 1)) + assert stats.printer_name == "MK4" + + # usage stats + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/stats/printers/uuid/printing_not_printing", + json={**common_stats, "data": [{"name": "printing", "value": 100}]}, + status=200, + ) + u_stats = client.get_printer_usage_stats("uuid", to_time=1672617600) + assert u_stats.printer_uuid == "uuid" + + # planned tasks + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/stats/printers/uuid/planned_tasks", + json={**common_stats, "xAxis": [], "series": {"uuid": "uuid", "name": "MK4", "data": []}}, + status=200, + ) + p_tasks = client.get_printer_planned_tasks_stats("uuid", from_time=1672531200) + assert len(p_tasks.time_axis) == 0 + + # jobs success + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/stats/printers/uuid/jobs_success", + json={**common_stats, "xAxis": [], "series": [], "time_shift": "0"}, + status=200, + ) + js_stats = client.get_printer_jobs_success_stats("uuid", to_time=datetime.datetime.now()) + assert js_stats.printer_name == "MK4" + + +@responses.activate +def test_printer_files_and_storages(client): + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/uuid/files", + json={"files": [{"name": "test.gcode", "size": 100, "type": "FILE"}]}, + status=200, + ) + files = client.get_printer_files("uuid") + assert len(files) == 1 + + # Test list response for storages + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/uuid/storages", + json=[{"name": "USB", "type": "USB", "path": "/usb", "mountpoint": "/usb", "read_only": False}], + status=200, + ) + storages = client.get_printer_storages("uuid") + assert len(storages) == 1 + assert storages[0].name == "USB" + + # Test dict response for storages + responses.replace( + responses.GET, + "https://connect.prusa3d.com/app/printers/uuid/storages", + json={"storages": [{"name": "SD", "type": "SD", "path": "/sd", "mountpoint": "/sd", "read_only": True}]}, + status=200, + ) + storages2 = client.get_printer_storages("uuid") + assert len(storages2) == 1 + assert storages2[0].name == "SD" + + +@responses.activate +def test_job_failure_reason(client): + responses.add(responses.PATCH, "https://connect.prusa3d.com/app/printers/uuid/jobs/1", status=204) + assert client.set_job_failure_reason("uuid", 1, models.JobFailureTag.OTHER, "Test note") is True + + +@responses.activate +def test_get_printer_queue_quirks(client): + # Test dictionary with planned_jobs + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/uuid/queue", + json={"planned_jobs": [{"id": 1, "state": "PLANNED"}]}, + status=200, + ) + queue = client.get_printer_queue("uuid") + assert len(queue) == 1 + + # Test list format + responses.replace( + responses.GET, + "https://connect.prusa3d.com/app/printers/uuid/queue", + json=[{"id": 2, "state": "PLANNED"}], + status=200, + ) + queue = client.get_printer_queue("uuid") + assert len(queue) == 1 + assert queue[0].id == 2 + + +@responses.activate +def test_compatibility_error_and_redaction(client): + # Mock get_supported_commands to trigger missing commands + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/uuid/commands", + json={"commands": []}, + status=200, + ) + # Mock get_printer for redaction test + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/uuid", + json={"uuid": "uuid-123", "name": "Secret Printer", "serial": "SN001", "telemetry": {"temp_nozzle": 200}}, + status=200, + ) + + with pytest.raises(exceptions.PrusaCompatibilityError) as excinfo: + client.get_supported_commands("uuid") + + report = excinfo.value.report_data + assert "STOP_PRINT" in excinfo.value.missing_commands + # Check redaction + details = report["printer_details"] + assert details["uuid"] == "[REDACTED]" + assert details["name"] == "[REDACTED]" + assert details["serial"] == "[REDACTED]" + assert details["telemetry"]["temp_nozzle"] == 200 + + +@responses.activate +def test_execute_printer_command_validation(client): + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers/uuid/commands", + json={ + "commands": [ + { + "command": "MOVE_Z", + "args": [ + {"name": "z", "type": "number", "required": True}, + {"name": "speed", "type": "integer", "required": False}, + {"name": "msg", "type": "string", "required": False}, + {"name": "active", "type": "boolean", "required": False}, + ], + }, + {"command": "STOP_PRINT", "args": []}, + {"command": "PAUSE_PRINT", "args": []}, + ] + }, + status=200, + ) + + # Valid call + responses.add(responses.POST, "https://connect.prusa3d.com/app/printers/uuid/commands/sync", status=204) + assert ( + client.execute_printer_command("uuid", "MOVE_Z", {"z": 10.5, "speed": 100, "msg": "hi", "active": True}) is True + ) + + # Unsupported command + with pytest.raises(ValueError, match="is not supported"): + client.execute_printer_command("uuid", "G28") + + # Missing required arg + with pytest.raises(ValueError, match="Missing required argument 'z'"): + client.execute_printer_command("uuid", "MOVE_Z", {"speed": 100}) + + # Invalid types + with pytest.raises(ValueError, match="must be a number"): + client.execute_printer_command("uuid", "MOVE_Z", {"z": "high"}) + with pytest.raises(ValueError, match="must be an integer"): + client.execute_printer_command("uuid", "MOVE_Z", {"z": 10, "speed": "fast"}) + with pytest.raises(ValueError, match="must be a string"): + client.execute_printer_command("uuid", "MOVE_Z", {"z": 10, "msg": 123}) + with pytest.raises(ValueError, match="must be a boolean"): + client.execute_printer_command("uuid", "MOVE_Z", {"z": 10, "active": 1}) + + +@responses.activate +def test_validate_gcode_wrapper(client, tmp_path): + # Create a dummy gcode file + gcode_file = tmp_path / "test.gcode" + gcode_file.write_text("; HEADER\n; estimated printing time (normal mode) = 1h 2m 3s\n") + + metadata = client.validate_gcode(gcode_file) + assert metadata.estimated_time == 3723 + + +@responses.activate +def test_cache_save_error_handling(client, tmp_path): + # Setup client with a cache dir that will fail on mkdir + bad_cache = tmp_path / "file_not_dir" + bad_cache.write_text("not a directory") + + client_bad_cache = PrusaConnectClient(credentials=MockCredentials(), cache_dir=bad_cache) + + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/printers", + json={"printers": [{"uuid": "uuid", "name": "MK4", "state": "IDLE"}]}, + status=200, + ) + + # Should not crash even if cache saving fails + printers = client_bad_cache.printers.list_printers() + assert len(printers) == 1 + + +@responses.activate +def test_file_management_more(client): + responses.add( + responses.GET, + "https://connect.prusa3d.com/app/teams/1/files/abc", + json={"name": "test.gcode", "size": 100, "hash": "abc", "type": "PRINT_FILE"}, + status=200, + ) + file_info = client.get_team_file(1, "abc") + assert file_info.hash == "abc" + + responses.add( + responses.GET, "https://connect.prusa3d.com/app/teams/1/files/abc/raw", body=b"gcode_content", status=200 + ) + content = client.download_team_file(1, "abc") + assert content == b"gcode_content" + + +@responses.activate +def test_snapshot(client): + responses.add( + responses.GET, "https://connect.prusa3d.com/app/cameras/1/snapshots/last", body=b"image_data", status=200 + ) + snap = client.get_snapshot("1") + assert snap == b"image_data" + + responses.add(responses.POST, "https://connect.prusa3d.com/app/cameras/camtoken/snapshots", status=204) + assert client.trigger_snapshot("camtoken") is True + + +@responses.activate +def test_raw_request(client): + responses.add(responses.GET, "https://connect.prusa3d.com/app/raw", body="raw content", status=200) + resp = client._request("GET", "/app/raw", raw=True) + assert resp.text == "raw content" + + +def test_request_network_error(client): + # PrusaNetworkError + with MagicMock() as mock_session: + mock_session.request.side_effect = requests.exceptions.ConnectionError("Failed") + client._session = mock_session + with pytest.raises(exceptions.PrusaNetworkError): + client.api_request("GET", "/any") diff --git a/tests/unit_tests/test_stats.py b/tests/unit_tests/test_stats.py new file mode 100644 index 0000000..3427c83 --- /dev/null +++ b/tests/unit_tests/test_stats.py @@ -0,0 +1,111 @@ +import datetime +from collections.abc import MutableMapping + +import pytest +import responses + +from prusa.connect.client import PrusaConnectClient + + +class MockCredentials: + """A dummy authentication strategy for testing.""" + + def before_request(self, headers: MutableMapping[str, str | bytes]) -> None: + headers["Authorization"] = "Bearer mock_token" + + +@pytest.fixture +def client(): + return PrusaConnectClient(credentials=MockCredentials()) + + +@responses.activate +def test_get_printer_usage_stats(client): + uuid = "test-uuid" + responses.add( + responses.GET, + f"https://connect.prusa3d.com/app/stats/printers/{uuid}/printing_not_printing", + json={ + "name": "printer-name", + "uuid": uuid, + "data": [{"name": "printing", "value": 10}, {"name": "not_printing", "value": 90}], + "from": 12345, + "to": 67890, + }, + status=200, + ) + + stats = client.get_printer_usage_stats(uuid) + assert stats.printer_name == "printer-name" + assert len(stats.data) == 2 + assert stats.data[0].name == "printing" + assert stats.data[0].value == 10 + assert isinstance(stats.from_time, datetime.date) + + +@responses.activate +def test_get_printer_material_stats(client): + uuid = "test-uuid" + responses.add( + responses.GET, + f"https://connect.prusa3d.com/app/stats/printers/{uuid}/material_quantity", + json={ + "name": "printer-name", + "uuid": uuid, + "data": [{"name": "PLA", "value": 150}], + "from": 12345, + "to": 67890, + }, + status=200, + ) + + stats = client.get_printer_material_stats(uuid) + assert stats.printer_name == "printer-name" + assert len(stats.data) == 1 + assert stats.data[0]["name"] == "PLA" + + +@responses.activate +def test_get_printer_planned_tasks_stats(client): + uuid = "test-uuid" + responses.add( + responses.GET, + f"https://connect.prusa3d.com/app/stats/printers/{uuid}/planned_tasks", + json={ + "xAxis": [0, 1], + "series": {"uuid": uuid, "name": "printer-name", "data": [[0, 5], [1, 2]]}, + "from": 12345, + "to": 67890, + }, + status=200, + ) + + stats = client.get_printer_planned_tasks_stats(uuid) + assert stats.series.printer_name == "printer-name" + assert stats.time_axis == [0, 1] + assert stats.series.data[0] == (0, 5) + + +@responses.activate +def test_get_printer_jobs_success_stats(client): + uuid = "test-uuid" + responses.add( + responses.GET, + f"https://connect.prusa3d.com/app/stats/printers/{uuid}/jobs_success", + json={ + "xAxis": ["2026-02-13"], + "name": "printer-name", + "uuid": uuid, + "series": [{"name": "FIN_OK", "data": [5]}], + "from": 12345, + "to": 67890, + "time_shift": "+00:00", + }, + status=200, + ) + + stats = client.get_printer_jobs_success_stats(uuid) + assert stats.printer_name == "printer-name" + assert stats.date_axis == ["2026-02-13"] + assert stats.series[0].status == "FIN_OK" + assert stats.series[0].data == [5] diff --git a/uv.lock b/uv.lock index 42ebcad..a6a936d 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,7 @@ requires-python = ">=3.12" [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, @@ -14,7 +14,7 @@ wheels = [ [[package]] name = "anyio" version = "4.12.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -27,7 +27,7 @@ wheels = [ [[package]] name = "application-file-scanner" version = "0.6.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "py-walk" }, { name = "typing-extensions" }, @@ -40,7 +40,7 @@ wheels = [ [[package]] name = "application-properties" version = "0.9.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pyjson5" }, { name = "pyyaml" }, @@ -55,7 +55,7 @@ wheels = [ [[package]] name = "attrs" version = "25.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, @@ -64,7 +64,7 @@ wheels = [ [[package]] name = "babel" version = "2.18.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, @@ -73,7 +73,7 @@ wheels = [ [[package]] name = "backports-zstd" version = "1.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/36a5182ce1d8ef9ef32bff69037bd28b389bbdb66338f8069e61da7028cb/backports_zstd-1.3.0.tar.gz", hash = "sha256:e8b2d68e2812f5c9970cabc5e21da8b409b5ed04e79b4585dbffa33e9b45ebe2", size = 997138, upload-time = "2025-12-29T17:28:06.143Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/72/d4/356da49d3053f4bc50e71a8535631b57bc9ca4e8c6d2442e073e0ab41c44/backports_zstd-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4a292e357f3046d18766ce06d990ccbab97411708d3acb934e63529c2ea7786", size = 435972, upload-time = "2025-12-29T17:26:18.752Z" }, @@ -132,7 +132,7 @@ wheels = [ [[package]] name = "backrefs" version = "6.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, @@ -146,7 +146,7 @@ wheels = [ [[package]] name = "beautifulsoup4" version = "4.14.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, @@ -159,7 +159,7 @@ wheels = [ [[package]] name = "beautysh" version = "6.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama" }, { name = "editorconfig" }, @@ -172,7 +172,7 @@ wheels = [ [[package]] name = "better-exceptions" version = "0.3.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -184,7 +184,7 @@ wheels = [ [[package]] name = "bidict" version = "0.23.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, @@ -193,7 +193,7 @@ wheels = [ [[package]] name = "cachecontrol" version = "0.14.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "msgpack" }, { name = "requests" }, @@ -211,7 +211,7 @@ filecache = [ [[package]] name = "cairocffi" version = "1.7.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cffi" }, ] @@ -223,7 +223,7 @@ wheels = [ [[package]] name = "cairosvg" version = "2.8.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cairocffi" }, { name = "cssselect2" }, @@ -239,7 +239,7 @@ wheels = [ [[package]] name = "certifi" version = "2026.1.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, @@ -248,7 +248,7 @@ wheels = [ [[package]] name = "cffi" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] @@ -305,7 +305,7 @@ wheels = [ [[package]] name = "cfgv" version = "3.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, @@ -314,7 +314,7 @@ wheels = [ [[package]] name = "charset-normalizer" version = "3.4.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, @@ -371,7 +371,7 @@ wheels = [ [[package]] name = "click" version = "8.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -383,7 +383,7 @@ wheels = [ [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple" } +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" }, @@ -392,7 +392,7 @@ wheels = [ [[package]] name = "columnar" version = "1.4.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "toolz" }, { name = "wcwidth" }, @@ -405,7 +405,7 @@ wheels = [ [[package]] name = "cryptography" version = "46.0.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] @@ -449,7 +449,7 @@ wheels = [ [[package]] name = "cssbeautifier" version = "1.15.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "editorconfig" }, { name = "jsbeautifier" }, @@ -463,13 +463,13 @@ wheels = [ [[package]] name = "csscompressor" version = "0.9.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808, upload-time = "2017-11-26T21:13:08.238Z" } [[package]] name = "cssselect2" version = "0.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "tinycss2" }, { name = "webencodings" }, @@ -482,7 +482,7 @@ wheels = [ [[package]] name = "cyclopts" version = "4.5.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "attrs" }, { name = "docstring-parser" }, @@ -504,7 +504,7 @@ mkdocs = [ [[package]] name = "deepdiff" version = "8.6.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "orderly-set" }, ] @@ -516,7 +516,7 @@ wheels = [ [[package]] name = "defusedxml" version = "0.7.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, @@ -525,7 +525,7 @@ wheels = [ [[package]] name = "distlib" version = "0.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, @@ -534,7 +534,7 @@ wheels = [ [[package]] name = "docstring-parser" version = "0.17.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, @@ -543,7 +543,7 @@ wheels = [ [[package]] name = "docutils" version = "0.22.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, @@ -552,7 +552,7 @@ wheels = [ [[package]] name = "editorconfig" version = "0.17.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "filelock" version = "3.20.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, @@ -570,7 +570,7 @@ wheels = [ [[package]] name = "ghp-import" version = "2.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "python-dateutil" }, ] @@ -582,7 +582,7 @@ wheels = [ [[package]] name = "gitdb" version = "4.0.12" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "smmap" }, ] @@ -594,7 +594,7 @@ wheels = [ [[package]] name = "gitpython" version = "3.1.46" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "gitdb" }, ] @@ -606,7 +606,7 @@ wheels = [ [[package]] name = "griffe" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "griffecli" }, { name = "griffelib" }, @@ -618,7 +618,7 @@ wheels = [ [[package]] name = "griffecli" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama" }, { name = "griffelib" }, @@ -630,7 +630,7 @@ wheels = [ [[package]] name = "griffelib" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] @@ -638,7 +638,7 @@ wheels = [ [[package]] name = "grpcio" version = "1.78.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -679,7 +679,7 @@ wheels = [ [[package]] name = "grpcio-tools" version = "1.78.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, @@ -722,7 +722,7 @@ wheels = [ [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, @@ -731,7 +731,7 @@ wheels = [ [[package]] name = "hatch" version = "1.16.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "backports-zstd", marker = "python_full_version < '3.14'" }, { name = "click" }, @@ -759,7 +759,7 @@ wheels = [ [[package]] name = "hatch-mkdocs" version = "0.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "hatch" }, { name = "mkdocs-get-deps" }, @@ -772,7 +772,7 @@ wheels = [ [[package]] name = "hatch-protobuf" version = "0.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "grpcio-tools" }, { name = "hatchling" }, @@ -785,7 +785,7 @@ wheels = [ [[package]] name = "hatchling" version = "1.28.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "packaging" }, { name = "pathspec" }, @@ -800,7 +800,7 @@ wheels = [ [[package]] name = "htmlmin2" version = "0.1.13" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } wheels = [ { url = "https://files.pythonhosted.org/packages/be/31/a76f4bfa885f93b8167cb4c85cf32b54d1f64384d0b897d45bc6d19b7b45/htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2", size = 34486, upload-time = "2023-03-14T21:28:30.388Z" }, ] @@ -808,7 +808,7 @@ wheels = [ [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "certifi" }, { name = "h11" }, @@ -821,7 +821,7 @@ wheels = [ [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "anyio" }, { name = "certifi" }, @@ -836,7 +836,7 @@ wheels = [ [[package]] name = "hyperlink" version = "21.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "idna" }, ] @@ -848,7 +848,7 @@ wheels = [ [[package]] name = "identify" version = "2.6.16" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, @@ -857,7 +857,7 @@ wheels = [ [[package]] name = "idna" version = "3.11" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } 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" }, @@ -866,7 +866,7 @@ wheels = [ [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi.org/simple" } +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" }, @@ -875,7 +875,7 @@ wheels = [ [[package]] name = "jaraco-classes" version = "3.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "more-itertools" }, ] @@ -887,7 +887,7 @@ wheels = [ [[package]] name = "jaraco-context" version = "6.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, @@ -896,7 +896,7 @@ wheels = [ [[package]] name = "jaraco-functools" version = "4.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "more-itertools" }, ] @@ -908,7 +908,7 @@ wheels = [ [[package]] name = "jeepney" version = "0.9.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, @@ -917,7 +917,7 @@ wheels = [ [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markupsafe" }, ] @@ -929,7 +929,7 @@ wheels = [ [[package]] name = "jsbeautifier" version = "1.15.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "editorconfig" }, { name = "six" }, @@ -942,13 +942,13 @@ wheels = [ [[package]] name = "jsmin" version = "3.0.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" } [[package]] name = "keyring" version = "25.7.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "jaraco-classes" }, { name = "jaraco-context" }, @@ -965,7 +965,7 @@ wheels = [ [[package]] name = "markdown" version = "3.10.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, @@ -974,7 +974,7 @@ wheels = [ [[package]] name = "markdown-callouts" version = "0.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown" }, ] @@ -986,7 +986,7 @@ wheels = [ [[package]] name = "markdown-gfm-admonition" version = "0.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown" }, ] @@ -998,7 +998,7 @@ wheels = [ [[package]] name = "markdown-it-py" version = "4.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mdurl" }, ] @@ -1010,7 +1010,7 @@ wheels = [ [[package]] name = "markupsafe" version = "3.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, @@ -1073,7 +1073,7 @@ wheels = [ [[package]] name = "mdformat" version = "1.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown-it-py" }, ] @@ -1085,7 +1085,7 @@ wheels = [ [[package]] name = "mdformat-beautysh" version = "1.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "beautysh" }, { name = "mdformat" }, @@ -1098,7 +1098,7 @@ wheels = [ [[package]] name = "mdformat-config" version = "0.2.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mdformat" }, { name = "ruamel-yaml" }, @@ -1112,7 +1112,7 @@ wheels = [ [[package]] name = "mdformat-footnote" version = "0.1.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mdformat" }, { name = "mdit-py-plugins" }, @@ -1125,7 +1125,7 @@ wheels = [ [[package]] name = "mdformat-front-matters" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mdformat" }, { name = "mdit-py-plugins" }, @@ -1140,7 +1140,7 @@ wheels = [ [[package]] name = "mdformat-gfm" version = "1.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown-it-py" }, { name = "mdformat" }, @@ -1155,7 +1155,7 @@ wheels = [ [[package]] name = "mdformat-mkdocs" version = "5.1.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mdformat" }, { name = "mdformat-gfm" }, @@ -1184,7 +1184,7 @@ recommended = [ [[package]] name = "mdformat-ruff" version = "0.1.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "ruff" }, ] @@ -1196,7 +1196,7 @@ wheels = [ [[package]] name = "mdformat-simple-breaks" version = "0.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mdformat" }, ] @@ -1208,7 +1208,7 @@ wheels = [ [[package]] name = "mdformat-web" version = "0.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "beautifulsoup4" }, { name = "cssbeautifier" }, @@ -1223,7 +1223,7 @@ wheels = [ [[package]] name = "mdformat-wikilink" version = "0.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown-it-py" }, { name = "mdformat" }, @@ -1236,7 +1236,7 @@ wheels = [ [[package]] name = "mdit-py-plugins" version = "0.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown-it-py" }, ] @@ -1248,7 +1248,7 @@ wheels = [ [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://pypi.org/simple" } +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" }, @@ -1257,7 +1257,7 @@ wheels = [ [[package]] name = "mergedeep" version = "1.3.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, @@ -1266,7 +1266,7 @@ wheels = [ [[package]] name = "mkdocs" version = "1.6.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "click" }, { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1290,7 +1290,7 @@ wheels = [ [[package]] name = "mkdocs-autorefs" version = "1.4.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown" }, { name = "markupsafe" }, @@ -1304,7 +1304,7 @@ wheels = [ [[package]] name = "mkdocs-get-deps" version = "0.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mergedeep" }, { name = "platformdirs" }, @@ -1318,7 +1318,7 @@ wheels = [ [[package]] name = "mkdocs-git-committers-plugin-2" version = "2.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "gitpython" }, { name = "mkdocs" }, @@ -1332,7 +1332,7 @@ wheels = [ [[package]] name = "mkdocs-git-revision-date-localized-plugin" version = "1.5.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "babel" }, { name = "gitpython" }, @@ -1347,7 +1347,7 @@ wheels = [ [[package]] name = "mkdocs-material" version = "9.7.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "babel" }, { name = "backrefs" }, @@ -1380,7 +1380,7 @@ recommended = [ [[package]] name = "mkdocs-material-extensions" version = "1.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, @@ -1389,7 +1389,7 @@ wheels = [ [[package]] name = "mkdocs-minify-plugin" version = "0.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "csscompressor" }, { name = "htmlmin2" }, @@ -1404,7 +1404,7 @@ wheels = [ [[package]] name = "mkdocs-redirects" version = "1.2.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "mkdocs" }, ] @@ -1416,7 +1416,7 @@ wheels = [ [[package]] name = "mkdocs-rss-plugin" version = "1.17.9" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cachecontrol", extra = ["filecache"] }, { name = "gitpython" }, @@ -1432,7 +1432,7 @@ wheels = [ [[package]] name = "mkdocstrings" version = "1.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "jinja2" }, { name = "markdown" }, @@ -1454,7 +1454,7 @@ python = [ [[package]] name = "mkdocstrings-python" version = "2.0.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "griffe" }, { name = "mkdocs-autorefs" }, @@ -1468,7 +1468,7 @@ wheels = [ [[package]] name = "more-itertools" version = "10.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, @@ -1477,7 +1477,7 @@ wheels = [ [[package]] name = "msgpack" version = "1.1.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, @@ -1521,7 +1521,7 @@ wheels = [ [[package]] name = "mypy-protobuf" version = "5.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "protobuf" }, { name = "types-protobuf" }, @@ -1534,7 +1534,7 @@ wheels = [ [[package]] name = "nodeenv" version = "1.10.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, @@ -1543,7 +1543,7 @@ wheels = [ [[package]] name = "orderly-set" version = "5.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, @@ -1552,7 +1552,7 @@ wheels = [ [[package]] name = "packaging" version = "26.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, @@ -1561,7 +1561,7 @@ wheels = [ [[package]] name = "paginate" version = "0.5.7" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, @@ -1570,7 +1570,7 @@ wheels = [ [[package]] name = "pathspec" version = "1.0.4" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, @@ -1579,7 +1579,7 @@ wheels = [ [[package]] name = "pexpect" version = "4.9.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "ptyprocess" }, ] @@ -1591,7 +1591,7 @@ wheels = [ [[package]] name = "pillow" version = "12.1.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, @@ -1660,7 +1660,7 @@ wheels = [ [[package]] name = "platformdirs" version = "4.5.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, @@ -1669,7 +1669,7 @@ wheels = [ [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi.org/simple" } +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" }, @@ -1678,7 +1678,7 @@ wheels = [ [[package]] name = "pre-commit" version = "4.5.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cfgv" }, { name = "identify" }, @@ -1694,7 +1694,7 @@ wheels = [ [[package]] name = "protobuf" version = "6.33.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, @@ -1814,7 +1814,7 @@ docs = [ [[package]] name = "ptyprocess" version = "0.7.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, @@ -1823,7 +1823,7 @@ wheels = [ [[package]] name = "py-walk" version = "0.3.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "sly" }, ] @@ -1835,7 +1835,7 @@ wheels = [ [[package]] name = "pycparser" version = "3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, @@ -1844,7 +1844,7 @@ wheels = [ [[package]] name = "pydantic" version = "2.12.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, @@ -1859,7 +1859,7 @@ wheels = [ [[package]] name = "pydantic-core" version = "2.41.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -1930,7 +1930,7 @@ wheels = [ [[package]] name = "pydantic-settings" version = "2.12.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, @@ -1944,7 +1944,7 @@ wheels = [ [[package]] name = "pygments" version = "2.19.2" -source = { registry = "https://pypi.org/simple" } +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" }, @@ -1953,7 +1953,7 @@ wheels = [ [[package]] name = "pyjson5" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/6e/d9/005aaaf5077cde946282b22da9404965477fb140fa6836b52d2e0955a391/pyjson5-2.0.0.tar.gz", hash = "sha256:7ccc98586cf87dfeadfa76de8df4c9cb0c3d21d1b559e28812dd9633748d6e25", size = 305865, upload-time = "2025-10-02T00:23:02.154Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d0/25/429e6cc1b6ba7a1ce730f172d8653f16dfff991de7c1122627b5d9a7dfd6/pyjson5-2.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dbb701b2b19ef5860a2409baf7fd576af8619fdaffa96ca37e0e8e0b2f030be8", size = 300589, upload-time = "2025-10-02T00:19:44.285Z" }, @@ -2041,7 +2041,7 @@ wheels = [ [[package]] name = "pymarkdownlnt" version = "0.9.35" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "application-file-scanner" }, { name = "application-properties" }, @@ -2056,7 +2056,7 @@ wheels = [ [[package]] name = "pymdown-extensions" version = "10.20.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, @@ -2069,7 +2069,7 @@ wheels = [ [[package]] name = "pyproject-hooks" version = "1.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, @@ -2078,7 +2078,7 @@ wheels = [ [[package]] name = "pyrefly" version = "0.50.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/99/74/f59a827418a74d5163d600db0e99b29a81cc7265ce62694dbfa0407bd95c/pyrefly-0.50.1.tar.gz", hash = "sha256:1859f36fb1dc4a903ba2298442c224dfadcda7fce5691aebd6bbc21c5f703299", size = 4901970, upload-time = "2026-01-29T00:10:06.42Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/6c/7135b5b2a4d8827b37d5bce0255cf993e4f418566810bbcd69b1c69b7acd/pyrefly-0.50.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:16ea4052b3df75206f5677a87ca7ee6c4c3a92b086081250206e98c60d85e7a8", size = 11832985, upload-time = "2026-01-29T00:09:43.739Z" }, @@ -2094,7 +2094,7 @@ wheels = [ [[package]] name = "pytest" version = "9.0.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -2110,7 +2110,7 @@ wheels = [ [[package]] name = "pytest-deepassert" version = "0.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "deepdiff" }, { name = "pytest" }, @@ -2124,7 +2124,7 @@ wheels = [ [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "six" }, ] @@ -2136,7 +2136,7 @@ wheels = [ [[package]] name = "python-dotenv" version = "1.2.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, @@ -2145,7 +2145,7 @@ wheels = [ [[package]] name = "python-engineio" version = "4.13.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "simple-websocket" }, ] @@ -2157,7 +2157,7 @@ wheels = [ [[package]] name = "python-socketio" version = "5.16.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "bidict" }, { name = "python-engineio" }, @@ -2170,7 +2170,7 @@ wheels = [ [[package]] name = "pywin32-ctypes" version = "0.2.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, @@ -2179,7 +2179,7 @@ wheels = [ [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, @@ -2225,7 +2225,7 @@ wheels = [ [[package]] name = "pyyaml-env-tag" version = "1.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pyyaml" }, ] @@ -2237,7 +2237,7 @@ wheels = [ [[package]] name = "requests" version = "2.32.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, @@ -2252,7 +2252,7 @@ wheels = [ [[package]] name = "responses" version = "0.25.8" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pyyaml" }, { name = "requests" }, @@ -2266,7 +2266,7 @@ wheels = [ [[package]] name = "rich" version = "14.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, @@ -2279,7 +2279,7 @@ wheels = [ [[package]] name = "rich-rst" version = "1.3.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "docutils" }, { name = "rich" }, @@ -2292,7 +2292,7 @@ wheels = [ [[package]] name = "ruamel-yaml" version = "0.19.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, @@ -2301,7 +2301,7 @@ wheels = [ [[package]] name = "ruff" version = "0.14.14" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, @@ -2327,7 +2327,7 @@ wheels = [ [[package]] name = "secretstorage" version = "3.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "cryptography" }, { name = "jeepney" }, @@ -2340,7 +2340,7 @@ wheels = [ [[package]] name = "setuptools" version = "82.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, @@ -2349,7 +2349,7 @@ wheels = [ [[package]] name = "shellingham" version = "1.5.4" -source = { registry = "https://pypi.org/simple" } +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" }, @@ -2358,7 +2358,7 @@ wheels = [ [[package]] name = "simple-websocket" version = "1.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "wsproto" }, ] @@ -2370,7 +2370,7 @@ wheels = [ [[package]] name = "six" version = "1.17.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, @@ -2379,7 +2379,7 @@ wheels = [ [[package]] name = "sly" version = "0.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/41/8a/59e943f7b27904c7756a7b565ffbd55f3841f5cd3d2da2b2b0713c49e488/sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024", size = 66702, upload-time = "2022-10-25T14:35:30.592Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8e/4d/c96d807295183f2360329cd8d8bf5e8072c53d664125b3858c04153f026e/sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133", size = 28864, upload-time = "2022-10-25T14:35:28.054Z" }, @@ -2388,7 +2388,7 @@ wheels = [ [[package]] name = "smmap" version = "5.0.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, @@ -2397,7 +2397,7 @@ wheels = [ [[package]] name = "soupsieve" version = "2.8.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, @@ -2406,7 +2406,7 @@ wheels = [ [[package]] name = "structlog" version = "25.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, @@ -2415,7 +2415,7 @@ wheels = [ [[package]] name = "taplo" version = "0.9.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/71/79/513513960377e1212a28446acb323cf77dfce162e825a822f035b02a422d/taplo-0.9.3.tar.gz", hash = "sha256:6b73b45b9adbd20189d8981ac9055d5465227c58bbe1b0646a7588a1a5c07a1a", size = 102556, upload-time = "2024-08-19T10:22:15.005Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/61/42/a93c18ebb7cf3ee2a7a30dd2fda654aca458956c3b64bdfb9d82b2c42679/taplo-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1c3db689406d538420c64aa779ac8694cf44c13a46e158d6df406de65980b9c7", size = 4248497, upload-time = "2024-08-19T10:21:59.954Z" }, @@ -2429,7 +2429,7 @@ wheels = [ [[package]] name = "tinycss2" version = "1.5.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "webencodings" }, ] @@ -2441,7 +2441,7 @@ wheels = [ [[package]] name = "toml" version = "0.10.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, @@ -2450,7 +2450,7 @@ wheels = [ [[package]] name = "tomli" version = "2.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, @@ -2495,7 +2495,7 @@ wheels = [ [[package]] name = "tomli-w" version = "1.2.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, @@ -2504,7 +2504,7 @@ wheels = [ [[package]] name = "tomlkit" version = "0.14.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, @@ -2513,7 +2513,7 @@ wheels = [ [[package]] name = "toolz" version = "1.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, @@ -2522,7 +2522,7 @@ wheels = [ [[package]] name = "trove-classifiers" version = "2026.1.14.14" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/d8/43/7935f8ea93fcb6680bc10a6fdbf534075c198eeead59150dd5ed68449642/trove_classifiers-2026.1.14.14.tar.gz", hash = "sha256:00492545a1402b09d4858605ba190ea33243d361e2b01c9c296ce06b5c3325f3", size = 16997, upload-time = "2026-01-14T14:54:50.526Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl", hash = "sha256:1f9553927f18d0513d8e5ff80ab8980b8202ce37ecae0e3274ed2ef11880e74d", size = 14197, upload-time = "2026-01-14T14:54:49.067Z" }, @@ -2531,7 +2531,7 @@ wheels = [ [[package]] name = "types-protobuf" version = "6.32.1.20251210" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/c2/59/c743a842911887cd96d56aa8936522b0cd5f7a7f228c96e81b59fced45be/types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763", size = 63900, upload-time = "2025-12-10T03:14:25.451Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/aa/43/58e75bac4219cbafee83179505ff44cae3153ec279be0e30583a73b8f108/types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140", size = 77921, upload-time = "2025-12-10T03:14:24.477Z" }, @@ -2540,7 +2540,7 @@ wheels = [ [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi.org/simple" } +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" }, @@ -2549,7 +2549,7 @@ wheels = [ [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -2561,7 +2561,7 @@ wheels = [ [[package]] name = "tzdata" version = "2025.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, @@ -2570,7 +2570,7 @@ wheels = [ [[package]] name = "urllib3" version = "2.6.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, @@ -2579,7 +2579,7 @@ wheels = [ [[package]] name = "userpath" version = "1.9.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "click" }, ] @@ -2591,7 +2591,7 @@ wheels = [ [[package]] name = "uv" version = "0.9.28" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/c2/7d/005ab1cab03ca928cef75b424284d14d62c5f18775cf8114a63f210a0c9c/uv-0.9.28.tar.gz", hash = "sha256:253c04b26fb40f74c56ead12ce83db3c018bdefde1fcd1a542bcb88fdca4189c", size = 3834456, upload-time = "2026-01-29T20:15:49.794Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/dc/e70698756f1bb74c88bf1eaea63a114a580a38f296ea1567a01db9007490/uv-0.9.28-py3-none-linux_armv6l.whl", hash = "sha256:aede961243bb2c0ca09d0e04ea0bf580d7128dd3b14661b79d133be9a5b69894", size = 22040477, upload-time = "2026-01-29T20:16:11.24Z" }, @@ -2617,7 +2617,7 @@ wheels = [ [[package]] name = "virtualenv" version = "20.36.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "distlib" }, { name = "filelock" }, @@ -2631,7 +2631,7 @@ wheels = [ [[package]] name = "watchdog" version = "6.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, @@ -2655,7 +2655,7 @@ wheels = [ [[package]] name = "wcwidth" version = "0.6.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, @@ -2664,7 +2664,7 @@ wheels = [ [[package]] name = "webencodings" version = "0.5.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, @@ -2673,7 +2673,7 @@ wheels = [ [[package]] name = "wsproto" version = "1.3.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "h11" }, ]