From 2578d12cee1edcaef810130130154e47d9745ed9 Mon Sep 17 00:00:00 2001 From: Derek Ditch Date: Sun, 22 Feb 2026 21:21:34 +0000 Subject: [PATCH 1/6] Refactor CLI with Cyclopts, split models/services, add stats & docs This PR refactors the Python Prusa Connect SDK client with several major improvements: - Migrate CLI from argparse to Cyclopts for improved argument parsing and DX - Use `app.meta` for global flags (token, host, verbose, output format) - Refactor `auth` command group with `login`, `show`, `clear` subcommands using `rich.prompt` - Use `Annotated` + `cyclopts.Parameter` throughout for rich help strings - Add config file support via `cli/config.py` - Split monolithic `models.py` into a `models/` package - Separate modules: `cameras`, `common`, `config`, `files`, `jobs`, `printers`, `stats`, `teams` - Add `services/` package with per-resource service classes - Services encapsulate SDK calls: `cameras`, `files`, `jobs`, `printers`, `stats`, `teams` - Add `stats` CLI command group with subcommands for printer/team stats - Restructure docs into `docs/cli/` and `docs/sdk/` subdirectories - Add `docs/installation.md` and `docs/authentication.md` - Add CLI quickstart and SDK quickstart guides - Update `mkdocs.yml` navigation - Add comprehensive test coverage for all CLI commands and services - Add `test_sdk_coverage.py` and `test_config.py` - Fix CI lint errors and apply ruff formatting - Disable mkdocs image optimization plugin to fix CI (missing `pngquant`) - Remove `.python-version` pin; update pre-commit config --- .github/workflows/dependabot-uv-lock.yml | 8 - .github/workflows/docs-check.yml | 2 + .gitignore | 2 + .pre-commit-config.yaml | 3 + .python-version | 1 - docs/.meta.yml | 1 - docs/api/.meta.yml | 1 - docs/authentication.md | 83 +++ docs/cli/quickstart.md | 135 ++++ docs/{cli_reference.md => cli/reference.md} | 4 - docs/examples.md | 22 +- docs/installation.md | 111 ++++ docs/quickstart.md | 95 --- docs/sdk/quickstart.md | 103 +++ mkdocs.yml | 31 +- overrides/partials/comments.html | 44 -- src/prusa/connect/client/cli/commands/api.py | 48 +- src/prusa/connect/client/cli/commands/auth.py | 75 ++- .../connect/client/cli/commands/camera.py | 47 +- src/prusa/connect/client/cli/commands/file.py | 1 + src/prusa/connect/client/cli/commands/job.py | 2 +- .../connect/client/cli/commands/printer.py | 130 ++-- .../connect/client/cli/commands/stats.py | 187 ++++++ src/prusa/connect/client/cli/commands/team.py | 17 + src/prusa/connect/client/cli/config.py | 2 +- src/prusa/connect/client/cli/main.py | 63 +- src/prusa/connect/client/consts.py | 2 +- src/prusa/connect/client/models.py | 535 --------------- src/prusa/connect/client/models/__init__.py | 118 ++++ src/prusa/connect/client/models/cameras.py | 62 ++ src/prusa/connect/client/models/common.py | 71 ++ src/prusa/connect/client/models/config.py | 24 + src/prusa/connect/client/models/files.py | 129 ++++ src/prusa/connect/client/models/jobs.py | 103 +++ src/prusa/connect/client/models/printers.py | 193 ++++++ src/prusa/connect/client/models/stats.py | 116 ++++ src/prusa/connect/client/models/teams.py | 36 + src/prusa/connect/client/sdk.py | 615 ++++++++---------- src/prusa/connect/client/services/base.py | 37 ++ src/prusa/connect/client/services/cameras.py | 46 ++ src/prusa/connect/client/services/files.py | 99 +++ src/prusa/connect/client/services/jobs.py | 86 +++ src/prusa/connect/client/services/printers.py | 146 +++++ src/prusa/connect/client/services/stats.py | 98 +++ src/prusa/connect/client/services/teams.py | 100 +++ tests/conftest.py | 26 + tests/unit_tests/test_caching.py | 37 +- tests/unit_tests/test_caching_ttl.py | 42 +- tests/unit_tests/test_camera_models.py | 75 +++ tests/unit_tests/test_cli_api.py | 70 ++ tests/unit_tests/test_cli_auth.py | 73 +++ tests/unit_tests/test_cli_camera.py | 132 ++++ tests/unit_tests/test_cli_file.py | 80 +++ tests/unit_tests/test_cli_job.py | 84 +++ tests/unit_tests/test_cli_printer.py | 207 ++++++ tests/unit_tests/test_cli_stats.py | 101 +++ tests/unit_tests/test_cli_team.py | 76 +++ tests/unit_tests/test_client.py | 5 +- tests/unit_tests/test_client_improvements.py | 6 +- tests/unit_tests/test_command_execution.py | 14 +- tests/unit_tests/test_config.py | 108 +++ tests/unit_tests/test_global_flags.py | 10 +- tests/unit_tests/test_job_features.py | 6 +- tests/unit_tests/test_retry.py | 2 +- tests/unit_tests/test_sdk_coverage.py | 518 +++++++++++++++ tests/unit_tests/test_stats.py | 111 ++++ 66 files changed, 4387 insertions(+), 1230 deletions(-) delete mode 100644 .python-version delete mode 100644 docs/.meta.yml delete mode 100644 docs/api/.meta.yml create mode 100644 docs/authentication.md create mode 100644 docs/cli/quickstart.md rename docs/{cli_reference.md => cli/reference.md} (75%) create mode 100644 docs/installation.md delete mode 100644 docs/quickstart.md create mode 100644 docs/sdk/quickstart.md delete mode 100644 overrides/partials/comments.html create mode 100644 src/prusa/connect/client/cli/commands/stats.py delete mode 100644 src/prusa/connect/client/models.py create mode 100644 src/prusa/connect/client/models/__init__.py create mode 100644 src/prusa/connect/client/models/cameras.py create mode 100644 src/prusa/connect/client/models/common.py create mode 100644 src/prusa/connect/client/models/config.py create mode 100644 src/prusa/connect/client/models/files.py create mode 100644 src/prusa/connect/client/models/jobs.py create mode 100644 src/prusa/connect/client/models/printers.py create mode 100644 src/prusa/connect/client/models/stats.py create mode 100644 src/prusa/connect/client/models/teams.py create mode 100644 src/prusa/connect/client/services/base.py create mode 100644 src/prusa/connect/client/services/cameras.py create mode 100644 src/prusa/connect/client/services/files.py create mode 100644 src/prusa/connect/client/services/jobs.py create mode 100644 src/prusa/connect/client/services/printers.py create mode 100644 src/prusa/connect/client/services/stats.py create mode 100644 src/prusa/connect/client/services/teams.py create mode 100644 tests/conftest.py create mode 100644 tests/unit_tests/test_camera_models.py create mode 100644 tests/unit_tests/test_cli_api.py create mode 100644 tests/unit_tests/test_cli_auth.py create mode 100644 tests/unit_tests/test_cli_camera.py create mode 100644 tests/unit_tests/test_cli_file.py create mode 100644 tests/unit_tests/test_cli_job.py create mode 100644 tests/unit_tests/test_cli_printer.py create mode 100644 tests/unit_tests/test_cli_stats.py create mode 100644 tests/unit_tests/test_cli_team.py create mode 100644 tests/unit_tests/test_config.py create mode 100644 tests/unit_tests/test_sdk_coverage.py create mode 100644 tests/unit_tests/test_stats.py 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/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..e20a660 100644 --- a/src/prusa/connect/client/cli/commands/camera.py +++ b/src/prusa/connect/client/cli/commands/camera.py @@ -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.get_cameras() + 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..56555e0 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, ): diff --git a/src/prusa/connect/client/cli/commands/job.py b/src/prusa/connect/client/cli/commands/job.py index 27ae8b1..11f7c83 100644 --- a/src/prusa/connect/client/cli/commands/job.py +++ b/src/prusa/connect/client/cli/commands/job.py @@ -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, diff --git a/src/prusa/connect/client/cli/commands/printer.py b/src/prusa/connect/client/cli/commands/printer.py index 656ec5d..097ca4b 100644 --- a/src/prusa/connect/client/cli/commands/printer.py +++ b/src/prusa/connect/client/cli/commands/printer.py @@ -77,7 +77,11 @@ 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) @@ -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(): @@ -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,25 +693,16 @@ 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() # Find team by team_name target_team = next((t for t in teams if t.name == p.team_name), None) @@ -711,7 +731,11 @@ 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() 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..6c48757 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") @@ -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..c92ce6b 100644 --- a/src/prusa/connect/client/sdk.py +++ b/src/prusa/connect/client/sdk.py @@ -11,9 +11,9 @@ """ import collections.abc -import json -import time +import datetime import typing +import warnings from pathlib import Path import pydantic @@ -24,6 +24,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 @@ -99,8 +107,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 +120,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 +131,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 +266,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 +306,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,72 +350,22 @@ 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]: + def get_printers(self, limit: int = 100, offset: int = 0) -> 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. + Args: + limit: Maximum number of printers to return. + offset: Number of printers to skip. 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 + warnings.warn( + "get_printers() is deprecated. Use client.printers.list() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.printers.list_printers(limit=limit, offset=offset) def get_printer(self, uuid: str) -> models.Printer: """Fetch details for a specific printer. @@ -314,15 +375,13 @@ def get_printer(self, uuid: str) -> models.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) + warnings.warn( + "get_printer() is deprecated. Use client.printers.get() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.printers.get(uuid) def get_file_list(self, team_id: int) -> list[models.File]: """Fetch files for a specific team. @@ -332,24 +391,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 +403,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 +418,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 +431,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,47 +443,41 @@ 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]: + def get_cameras(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. - - 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 [] + warnings.warn( + "get_cameras() is deprecated. Use client.cameras.list() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.cameras.list(limit, offset) - def get_teams(self) -> list[models.Team]: + def get_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. - - 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 + warnings.warn( + "get_teams() is deprecated. Use client.teams.list_teams() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.teams.list_teams(limit, offset) def get_team(self, team_id: int) -> models.Team: """Fetch detailed information for a specific team. @@ -462,8 +488,23 @@ def get_team(self, team_id: int) -> models.Team: Returns: A `Team` object. """ - data = self._request("GET", f"/users/teams/{team_id}") - return models.Team.model_validate(data) + warnings.warn( + "get_team() is deprecated. Use client.teams.get() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.teams.get(team_id) + + 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 list of `TeamUser` objects. + """ + return self.teams.list_users(team_id) def add_team_user( self, @@ -485,14 +526,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 +538,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,69 +553,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"]] + return self.jobs.list_printer_jobs(printer_uuid, state=state, limit=limit) - # Client-side filtering/limiting - if state: - state_set = set(state) - jobs = [j for j in jobs if j.state in state_set] + def get_printer_queue(self, printer_uuid: str, limit: int = 100, offset: int = 0) -> list[models.Job]: + """Fetch the print queue for a printer. - if limit is not None: - jobs = jobs[:limit] + Args: + printer_uuid: The printer UUID. + limit: Optional maximum number of jobs to return. + offset: Optional offset for pagination. - return jobs + Returns: + A list of `Job` objects representing the queue. + """ + return self.jobs.get_queue(printer_uuid, limit, offset) - def get_printer_queue(self, printer_uuid: str) -> list[models.Job]: - """Fetch the print queue for a printer. + 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. Args: printer_uuid: The printer UUID. + from_time: Optional start date or timestamp. + to_time: Optional end date or timestamp. Returns: - A list of `Job` objects representing the queue. + A `MaterialQuantity` object. + """ + return self.stats.get_material(printer_uuid, from_time, to_time) - Usage Example: - ```python - >>> queue = client.get_printer_queue("printer-uuid") - >>> if queue: - ... print(queue[0].state) - ``` + 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. + + Args: + printer_uuid: The printer UUID. + from_time: Optional start date or timestamp. + to_time: Optional end date or timestamp. + + Returns: + A `PrintingNotPrinting` object. """ - data = self._request("GET", f"/printers/{printer_uuid}/queue") + return self.stats.get_usage(printer_uuid, from_time, to_time) - # Structure from users reverse engineering: - # GET response usually: {"planned_jobs": [...] } - # POST response (adding): single object + 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. - 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"]] + Args: + printer_uuid: The printer UUID. + from_time: Optional start date or timestamp. + to_time: Optional end date or timestamp. - # 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)] + Returns: + A `PlannedTasks` object. + """ + return self.stats.get_planned_tasks(printer_uuid, from_time, to_time) - elif isinstance(data, list): - return [models.Job.model_validate(j) for j in data] + 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. - # Fallback empty - return [] + 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 send_command(self, printer_uuid: str, command: str, kwargs: dict | None = None) -> bool: """Send a command to a printer. @@ -612,19 +651,13 @@ def send_command(self, printer_uuid: str, command: str, kwargs: dict | None = No Returns: True if the command was successfully sent. - - Usage Example: - ```python - >>> client.send_command("printer-uuid", "PAUSE_PRINT") - ``` """ - payload: dict[str, typing.Any] = {"command": command} - if kwargs: - payload["kwargs"] = kwargs - - # discovery says /commands/sync is definitive - self._request("POST", f"/printers/{printer_uuid}/commands/sync", json=payload) - return True + warnings.warn( + "send_command() is deprecated. Use client.printers.send_command() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.printers.send_command(printer_uuid, command, kwargs) def get_supported_commands(self, printer_uuid: str) -> list[command_models.CommandDefinition]: """Fetch supported commands for a printer. @@ -638,101 +671,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 +721,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 +740,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 +757,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 +769,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 +780,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 +791,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 +803,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 +841,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 +853,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 +870,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 +883,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 +895,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 +909,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..e4cf029 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,9 +103,11 @@ 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 - with pytest.raises(Exception, match="Network Down"): + # This should trigger a network call because cache is expired + with ( + pytest.warns(DeprecationWarning, match="get_printers"), + pytest.raises(Exception, match="Network Down"), + ): mock_client.get_printers() @@ -118,7 +125,8 @@ 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 + with pytest.warns(DeprecationWarning, match="get_printers"): + printers = mock_client.get_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..c1782f8 --- /dev/null +++ b/tests/unit_tests/test_cli_camera.py @@ -0,0 +1,132 @@ +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) + 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.get_cameras.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.get_cameras.call_count == 3 + + +def test_cli_camera_list(mock_client): + mock_client.get_cameras.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.get_cameras.call_count == 2 + + +def test_cli_camera_snapshot(mock_client, tmp_path): + mock_client.get_cameras.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.get_cameras.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.get_cameras.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.get_cameras.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.get_cameras.return_value = [camera] + + with contextlib.suppress(SystemExit): + app(["camera", "show", "123456", "--detailed"], exit_on_error=False) + + assert mock_client.get_cameras.called + + +def test_cli_camera_show_not_found(mock_client): + """Verify camera show handles non-existent cameras.""" + mock_client.get_cameras.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..80ebfbb --- /dev/null +++ b/tests/unit_tests/test_cli_job.py @@ -0,0 +1,84 @@ +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) + 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 get_printers and then get_printer_jobs for each + mock_client.get_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.get_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..d699933 --- /dev/null +++ b/tests/unit_tests/test_cli_printer.py @@ -0,0 +1,207 @@ +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) + 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.get_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.get_printers.call_count == 3 + + +def test_printer_pause_resume(mock_client, mock_settings): + mock_client.send_command.return_value = True + + # Explicit ID + with contextlib.suppress(SystemExit): + app(["printer", "pause", "printer-1"], exit_on_error=False) + mock_client.send_command.assert_called_with("printer-1", "PAUSE_PRINT") + + # Default ID + with contextlib.suppress(SystemExit): + app(["printer", "resume"], exit_on_error=False) + mock_client.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.get_printer.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.get_printer.return_value = Printer.model_validate(SAMPLE_PRINTER) + mock_client.get_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..ab7b684 --- /dev/null +++ b/tests/unit_tests/test_cli_team.py @@ -0,0 +1,76 @@ +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) + 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.get_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.get_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.get_team.return_value = Team.model_validate(team_data) + + with contextlib.suppress(SystemExit): + app(["team", "show"], exit_on_error=False) + + mock_client.get_team.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..cf7112a 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -32,7 +32,8 @@ def test_get_printers_success(client): ) # 2. Call the method - printers = client.get_printers() + with pytest.warns(DeprecationWarning, match="get_printers"): + printers = client.get_printers() # 3. Assertions assert len(printers) == 1 @@ -46,5 +47,5 @@ def test_get_printers_success(client): def test_auth_failure_raises_exception(client): responses.add(responses.GET, "https://connect.prusa3d.com/app/printers", status=401) - with pytest.raises(PrusaAuthError): + with pytest.raises(PrusaAuthError), pytest.warns(DeprecationWarning, match="get_printers"): client.get_printers() diff --git a/tests/unit_tests/test_client_improvements.py b/tests/unit_tests/test_client_improvements.py index aa6241c..8761f22 100644 --- a/tests/unit_tests/test_client_improvements.py +++ b/tests/unit_tests/test_client_improvements.py @@ -27,7 +27,8 @@ def test_default_timeout(client): mock_response.headers = {} # Mock headers as a real dict mock_request.return_value = mock_response - client.get_printers() + with pytest.warns(DeprecationWarning, match="get_printers"): + client.get_printers() mock_request.assert_called() # Check that timeout=30.0 was passed @@ -46,7 +47,8 @@ def test_custom_timeout(): mock_response.headers = {} mock_request.return_value = mock_response - client.get_printers() + with pytest.warns(DeprecationWarning, match="get_printers"): + client.get_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..9f96126 100644 --- a/tests/unit_tests/test_global_flags.py +++ b/tests/unit_tests/test_global_flags.py @@ -22,9 +22,13 @@ ) 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), + # We expect deprecation warnings because the CLI commands use deprecated client methods + pytest.warns(DeprecationWarning, match="get_printers"), + ): + 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..a3cc45a 100644 --- a/tests/unit_tests/test_job_features.py +++ b/tests/unit_tests/test_job_features.py @@ -29,7 +29,8 @@ 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() + with pytest.warns(DeprecationWarning, match="get_printers"): + printers = client.get_printers() assert len(printers) == 1 assert printers[0].uuid == "uuid1" assert mock_req.call_count == 1 @@ -40,7 +41,8 @@ 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() + with pytest.warns(DeprecationWarning, match="get_printers"): + printers_cached = client.get_printers() assert len(printers_cached) == 1 assert printers_cached[0].uuid == "uuid1" diff --git a/tests/unit_tests/test_retry.py b/tests/unit_tests/test_retry.py index 20b087b..34f35b4 100644 --- a/tests/unit_tests/test_retry.py +++ b/tests/unit_tests/test_retry.py @@ -52,7 +52,7 @@ def test_retry_on_final_failure(mock_send, client): # Simulate MaxRetryError from urllib3 which requests wraps into RetryError mock_send.side_effect = RetryError("Max retries exceeded") - with pytest.raises(PrusaNetworkError) as exc: + with pytest.raises(PrusaNetworkError) as exc, pytest.warns(DeprecationWarning, match="get_printers"): client.get_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..e887d99 --- /dev/null +++ b/tests/unit_tests/test_sdk_coverage.py @@ -0,0 +1,518 @@ +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, + ) + with pytest.warns(DeprecationWarning, match="get_cameras"): + cameras = client.get_cameras() + 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 get_cameras uses "/cameras" + responses.replace( + responses.GET, "https://connect.prusa3d.com/app/cameras", json=[{"id": 2, "name": "Camera 2"}], status=200 + ) + with pytest.warns(DeprecationWarning, match="get_cameras"): + cameras2 = client.get_cameras() + 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) + with pytest.warns(DeprecationWarning, match="get_cameras"): + assert client.get_cameras() == [] + + +@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 + ) + with pytest.warns(DeprecationWarning, match="get_teams"): + teams = client.get_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, + ) + with pytest.warns(DeprecationWarning, match="get_team"): + team = client.get_team(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 + with pytest.warns(DeprecationWarning, match="get_printers"): + printers = client_bad_cache.get_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] From 09c3808c8a0d322a33c9b5142af1b5fe7ed12cdd Mon Sep 17 00:00:00 2001 From: Derek Ditch Date: Sun, 22 Feb 2026 23:51:23 +0000 Subject: [PATCH 2/6] remove deprecated shim methods before 1.0.0 stable release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prerelease versions (v1.0.0a0, v1.0.0a2) introduced DeprecationWarning wrappers on PrusaConnectClient as a transitional courtesy during the refactor to a service-based API. Per semver, prereleases carry no stability guarantee, so these shims are removed before the first stable release rather than carrying dead weight. Removed from sdk.py: - get_printers() → client.printers.list_printers() - get_printer() → client.printers.get() - get_cameras() → client.cameras.list() - get_teams() → client.teams.list_teams() - get_team() → client.teams.get() - send_command() → client.printers.send_command() CLI commands and all tests updated to call the service layer directly. Service attributes annotated at class level for correct type inference. --- examples/client_auth/client_auth.py | 2 +- examples/hello_world/hello_prusa.py | 2 +- .../connect/client/cli/commands/camera.py | 12 +- src/prusa/connect/client/cli/commands/file.py | 8 +- src/prusa/connect/client/cli/commands/job.py | 4 +- .../connect/client/cli/commands/printer.py | 16 +- src/prusa/connect/client/cli/commands/team.py | 4 +- src/prusa/connect/client/sdk.py | 116 +------ tests/unit_tests/test_caching_ttl.py | 10 +- tests/unit_tests/test_cli_camera.py | 23 +- tests/unit_tests/test_cli_job.py | 7 +- tests/unit_tests/test_cli_printer.py | 18 +- tests/unit_tests/test_cli_team.py | 9 +- tests/unit_tests/test_client.py | 7 +- tests/unit_tests/test_client_improvements.py | 6 +- tests/unit_tests/test_global_flags.py | 2 - tests/unit_tests/test_job_features.py | 18 +- tests/unit_tests/test_printer_details.py | 9 +- tests/unit_tests/test_retry.py | 4 +- tests/unit_tests/test_sdk_coverage.py | 20 +- uv.lock | 326 +++++++++--------- 21 files changed, 261 insertions(+), 362 deletions(-) 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/src/prusa/connect/client/cli/commands/camera.py b/src/prusa/connect/client/cli/commands/camera.py index e20a660..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 @@ -182,7 +182,7 @@ def camera_show( common.logger.debug("Command started", command="camera show", camera_id=camera_id, detailed=detailed) client = common.get_client() - 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) if not match: diff --git a/src/prusa/connect/client/cli/commands/file.py b/src/prusa/connect/client/cli/commands/file.py index 56555e0..8e47132 100644 --- a/src/prusa/connect/client/cli/commands/file.py +++ b/src/prusa/connect/client/cli/commands/file.py @@ -20,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 @@ -61,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 @@ -106,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 @@ -137,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 11f7c83..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 @@ -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 097ca4b..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") @@ -88,7 +88,7 @@ def printer_show( 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}") @@ -288,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 @@ -702,8 +702,8 @@ def printer_files_upload( 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() # 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: @@ -740,8 +740,8 @@ def printer_files_download( 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/team.py b/src/prusa/connect/client/cli/commands/team.py index 6c48757..7426405 100644 --- a/src/prusa/connect/client/cli/commands/team.py +++ b/src/prusa/connect/client/cli/commands/team.py @@ -17,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") @@ -50,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) diff --git a/src/prusa/connect/client/sdk.py b/src/prusa/connect/client/sdk.py index c92ce6b..575f6b6 100644 --- a/src/prusa/connect/client/sdk.py +++ b/src/prusa/connect/client/sdk.py @@ -6,14 +6,13 @@ 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 datetime import typing -import warnings from pathlib import Path import pydantic @@ -70,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, @@ -350,39 +357,6 @@ def api_request(self, method: str, endpoint: str, **kwargs: typing.Any) -> typin """ return self._request(method, endpoint, **kwargs) - def get_printers(self, limit: int = 100, offset: int = 0) -> list[models.Printer]: - """Fetch all printers associated with the account. - - Args: - limit: Maximum number of printers to return. - offset: Number of printers to skip. - - Returns: - A list of `Printer` objects. - """ - warnings.warn( - "get_printers() is deprecated. Use client.printers.list() instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.printers.list_printers(limit=limit, offset=offset) - - 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. - """ - warnings.warn( - "get_printer() is deprecated. Use client.printers.get() instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.printers.get(uuid) - def get_file_list(self, team_id: int) -> list[models.File]: """Fetch files for a specific team. @@ -445,56 +419,6 @@ def download_team_file(self, team_id: int, file_hash: str) -> bytes: """ return self.files.download(team_id, file_hash) - def get_cameras(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. - """ - warnings.warn( - "get_cameras() is deprecated. Use client.cameras.list() instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.cameras.list(limit, offset) - - def get_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. - """ - warnings.warn( - "get_teams() is deprecated. Use client.teams.list_teams() instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.teams.list_teams(limit, offset) - - def get_team(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. - """ - warnings.warn( - "get_team() is deprecated. Use client.teams.get() instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.teams.get(team_id) - def get_team_users(self, team_id: int) -> list[models.TeamUser]: """Fetch all users associated with a team. @@ -641,24 +565,6 @@ def get_printer_jobs_success_stats( """ return self.stats.get_jobs_success(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. - - Args: - printer_uuid: The printer UUID. - command: The command string (e.g., 'PAUSE_PRINT', 'MOVE_Z'). - kwargs: Optional arguments for the command. - - Returns: - True if the command was successfully sent. - """ - warnings.warn( - "send_command() is deprecated. Use client.printers.send_command() instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.printers.send_command(printer_uuid, command, kwargs) - def get_supported_commands(self, printer_uuid: str) -> list[command_models.CommandDefinition]: """Fetch supported commands for a printer. diff --git a/tests/unit_tests/test_caching_ttl.py b/tests/unit_tests/test_caching_ttl.py index e4cf029..fd7c80f 100644 --- a/tests/unit_tests/test_caching_ttl.py +++ b/tests/unit_tests/test_caching_ttl.py @@ -104,11 +104,8 @@ def test_cache_ttl_expiration_printers(mock_client, mock_cache_dir): mock_client._session.request.side_effect = Exception("Network Down") # This should trigger a network call because cache is expired - with ( - pytest.warns(DeprecationWarning, match="get_printers"), - pytest.raises(Exception, match="Network Down"), - ): - mock_client.get_printers() + with pytest.raises(Exception, match="Network Down"): + mock_client.printers.list_printers() def test_cache_ttl_valid_printers_fallback(mock_client, mock_cache_dir): @@ -126,7 +123,6 @@ def test_cache_ttl_valid_printers_fallback(mock_client, mock_cache_dir): mock_client._session.request.side_effect = Exception("Network Down") # Execute # Should read from cache despite network error - with pytest.warns(DeprecationWarning, match="get_printers"): - printers = mock_client.get_printers() + printers = mock_client.printers.list_printers() assert len(printers) == 1 assert printers[0].name == "Cached" diff --git a/tests/unit_tests/test_cli_camera.py b/tests/unit_tests/test_cli_camera.py index c1782f8..25e5dd8 100644 --- a/tests/unit_tests/test_cli_camera.py +++ b/tests/unit_tests/test_cli_camera.py @@ -29,6 +29,7 @@ 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 @@ -36,7 +37,7 @@ def mock_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.get_cameras.return_value = [camera] + mock_client.cameras.list.return_value = [camera] # Test showing by ID with contextlib.suppress(SystemExit): @@ -50,21 +51,21 @@ def test_cli_camera_show(mock_client): with contextlib.suppress(SystemExit): app(["camera", "show", "Buddy3D Camera"], exit_on_error=False) - assert mock_client.get_cameras.call_count == 3 + assert mock_client.cameras.list.call_count == 3 def test_cli_camera_list(mock_client): - mock_client.get_cameras.return_value = [models.Camera(id=1, name="Cam1", token="tok1")] + 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.get_cameras.call_count == 2 + assert mock_client.cameras.list.call_count == 2 def test_cli_camera_snapshot(mock_client, tmp_path): - mock_client.get_cameras.return_value = [models.Camera(id=123, name="Cam1")] + 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): @@ -74,7 +75,7 @@ def test_cli_camera_snapshot(mock_client, tmp_path): def test_cli_camera_trigger(mock_client): - mock_client.get_cameras.return_value = [models.Camera(id=1, token="tok1")] + 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) @@ -82,7 +83,7 @@ def test_cli_camera_trigger(mock_client): def test_cli_camera_move(mock_client): - mock_client.get_cameras.return_value = [models.Camera(id=1, token="tok1")] + 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): @@ -92,7 +93,7 @@ def test_cli_camera_move(mock_client): def test_cli_camera_adjust(mock_client): - mock_client.get_cameras.return_value = [models.Camera(id=1, token="tok1")] + 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): @@ -114,17 +115,17 @@ def test_cli_camera_set_current(): def test_cli_camera_show_detailed(mock_client): """Verify camera show --detailed command.""" camera = Camera.model_validate(SAMPLE_CAMERA_DATA) - mock_client.get_cameras.return_value = [camera] + mock_client.cameras.list.return_value = [camera] with contextlib.suppress(SystemExit): app(["camera", "show", "123456", "--detailed"], exit_on_error=False) - assert mock_client.get_cameras.called + assert mock_client.cameras.list.called def test_cli_camera_show_not_found(mock_client): """Verify camera show handles non-existent cameras.""" - mock_client.get_cameras.return_value = [] + mock_client.cameras.list.return_value = [] with pytest.raises(SystemExit) as e: app(["camera", "show", "nonexistent"], exit_on_error=False) diff --git a/tests/unit_tests/test_cli_job.py b/tests/unit_tests/test_cli_job.py index 80ebfbb..9398da9 100644 --- a/tests/unit_tests/test_cli_job.py +++ b/tests/unit_tests/test_cli_job.py @@ -22,6 +22,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 @@ -52,14 +53,14 @@ def test_job_list_team(mock_client): def test_job_list_aggregate(mock_client): - # Mocking get_printers and then get_printer_jobs for each - mock_client.get_printers.return_value = [Printer.model_validate({"uuid": "p1", "name": "Pr1"})] + # 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.get_printers.assert_called() + mock_client.printers.list_printers.assert_called() mock_client.get_printer_jobs.assert_called() diff --git a/tests/unit_tests/test_cli_printer.py b/tests/unit_tests/test_cli_printer.py index d699933..1c75585 100644 --- a/tests/unit_tests/test_cli_printer.py +++ b/tests/unit_tests/test_cli_printer.py @@ -24,6 +24,8 @@ def mock_client(): 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 @@ -37,7 +39,7 @@ def mock_settings(): def test_printer_list(mock_client): - mock_client.get_printers.return_value = [Printer.model_validate(SAMPLE_PRINTER)] + mock_client.printers.list_printers.return_value = [Printer.model_validate(SAMPLE_PRINTER)] with contextlib.suppress(SystemExit): app(["printer", "list"], exit_on_error=False) @@ -50,26 +52,26 @@ def test_printer_list(mock_client): with contextlib.suppress(SystemExit): app(["printer", "list", "--pattern", "MK*"], exit_on_error=False) - assert mock_client.get_printers.call_count == 3 + assert mock_client.printers.list_printers.call_count == 3 def test_printer_pause_resume(mock_client, mock_settings): - mock_client.send_command.return_value = True + 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.send_command.assert_called_with("printer-1", "PAUSE_PRINT") + 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.send_command.assert_called_with("default-uuid", "RESUME_PRINT") + 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.get_printer.return_value = Printer.model_validate({**SAMPLE_PRINTER, "job_info": {"id": 123}}) + 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 @@ -167,8 +169,8 @@ def test_printer_files_list(mock_client, mock_settings): def test_printer_files_upload_download(mock_client, mock_settings, tmp_path): # Setup mocks for printer details and teams - mock_client.get_printer.return_value = Printer.model_validate(SAMPLE_PRINTER) - mock_client.get_teams.return_value = [Team(id=1, name="Team A")] + 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" ) diff --git a/tests/unit_tests/test_cli_team.py b/tests/unit_tests/test_cli_team.py index ab7b684..a3fb536 100644 --- a/tests/unit_tests/test_cli_team.py +++ b/tests/unit_tests/test_cli_team.py @@ -14,6 +14,7 @@ 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 @@ -26,7 +27,7 @@ def mock_settings(): def test_team_list(mock_client): - mock_client.get_teams.return_value = [Team.model_validate(SAMPLE_TEAM)] + mock_client.teams.list_teams.return_value = [Team.model_validate(SAMPLE_TEAM)] with contextlib.suppress(SystemExit): app(["team", "list"], exit_on_error=False) @@ -35,17 +36,17 @@ def test_team_list(mock_client): with contextlib.suppress(SystemExit): app(["teams"], exit_on_error=False) - assert mock_client.get_teams.call_count == 2 + 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.get_team.return_value = Team.model_validate(team_data) + mock_client.teams.get.return_value = Team.model_validate(team_data) with contextlib.suppress(SystemExit): app(["team", "show"], exit_on_error=False) - mock_client.get_team.assert_called_with(1) + mock_client.teams.get.assert_called_with(1) def test_team_add_user(mock_client, mock_settings): diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index cf7112a..2c70293 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -32,8 +32,7 @@ def test_get_printers_success(client): ) # 2. Call the method - with pytest.warns(DeprecationWarning, match="get_printers"): - printers = client.get_printers() + printers = client.printers.list_printers() # 3. Assertions assert len(printers) == 1 @@ -47,5 +46,5 @@ def test_get_printers_success(client): def test_auth_failure_raises_exception(client): responses.add(responses.GET, "https://connect.prusa3d.com/app/printers", status=401) - with pytest.raises(PrusaAuthError), pytest.warns(DeprecationWarning, match="get_printers"): - client.get_printers() + with pytest.raises(PrusaAuthError): + client.printers.list_printers() diff --git a/tests/unit_tests/test_client_improvements.py b/tests/unit_tests/test_client_improvements.py index 8761f22..391a879 100644 --- a/tests/unit_tests/test_client_improvements.py +++ b/tests/unit_tests/test_client_improvements.py @@ -27,8 +27,7 @@ def test_default_timeout(client): mock_response.headers = {} # Mock headers as a real dict mock_request.return_value = mock_response - with pytest.warns(DeprecationWarning, match="get_printers"): - client.get_printers() + client.printers.list_printers() mock_request.assert_called() # Check that timeout=30.0 was passed @@ -47,8 +46,7 @@ def test_custom_timeout(): mock_response.headers = {} mock_request.return_value = mock_response - with pytest.warns(DeprecationWarning, match="get_printers"): - 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_global_flags.py b/tests/unit_tests/test_global_flags.py index 9f96126..8ffe652 100644 --- a/tests/unit_tests/test_global_flags.py +++ b/tests/unit_tests/test_global_flags.py @@ -25,8 +25,6 @@ def test_logging_levels(args, expected_level): with ( patch("prusa.connect.client.cli.common.structlog.make_filtering_bound_logger") as mock_maker, contextlib.suppress(SystemExit), - # We expect deprecation warnings because the CLI commands use deprecated client methods - pytest.warns(DeprecationWarning, match="get_printers"), ): main(args) diff --git a/tests/unit_tests/test_job_features.py b/tests/unit_tests/test_job_features.py index a3cc45a..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,8 +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 - with pytest.warns(DeprecationWarning, match="get_printers"): - printers = client.get_printers() + printers = client.printers.list_printers() assert len(printers) == 1 assert printers[0].uuid == "uuid1" assert mock_req.call_count == 1 @@ -41,8 +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") - with pytest.warns(DeprecationWarning, match="get_printers"): - printers_cached = client.get_printers() + printers_cached = client.printers.list_printers() assert len(printers_cached) == 1 assert printers_cached[0].uuid == "uuid1" @@ -73,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 @@ -91,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 @@ -111,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 34f35b4..6cb480d 100644 --- a/tests/unit_tests/test_retry.py +++ b/tests/unit_tests/test_retry.py @@ -52,7 +52,7 @@ def test_retry_on_final_failure(mock_send, client): # Simulate MaxRetryError from urllib3 which requests wraps into RetryError mock_send.side_effect = RetryError("Max retries exceeded") - with pytest.raises(PrusaNetworkError) as exc, pytest.warns(DeprecationWarning, match="get_printers"): - client.get_printers() + with pytest.raises(PrusaNetworkError) as exc: + 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 index e887d99..0948b98 100644 --- a/tests/unit_tests/test_sdk_coverage.py +++ b/tests/unit_tests/test_sdk_coverage.py @@ -114,8 +114,7 @@ def test_get_cameras(client): json={"cameras": [{"id": 1, "name": "Camera 1"}]}, status=200, ) - with pytest.warns(DeprecationWarning, match="get_cameras"): - cameras = client.get_cameras() + cameras = client.cameras.list() assert len(cameras) == 1 assert cameras[0].name == "Camera 1" @@ -124,19 +123,17 @@ def test_get_cameras(client): 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 get_cameras uses "/cameras" + # But cameras.list uses "/cameras" responses.replace( responses.GET, "https://connect.prusa3d.com/app/cameras", json=[{"id": 2, "name": "Camera 2"}], status=200 ) - with pytest.warns(DeprecationWarning, match="get_cameras"): - cameras2 = client.get_cameras() + 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) - with pytest.warns(DeprecationWarning, match="get_cameras"): - assert client.get_cameras() == [] + assert client.cameras.list() == [] @responses.activate @@ -144,8 +141,7 @@ 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 ) - with pytest.warns(DeprecationWarning, match="get_teams"): - teams = client.get_teams() + teams = client.teams.list_teams() assert len(teams) == 1 assert teams[0].name == "Team A" @@ -168,8 +164,7 @@ def test_get_teams_and_users(client): }, status=200, ) - with pytest.warns(DeprecationWarning, match="get_team"): - team = client.get_team(1) + team = client.teams.get(1) assert team.name == "Team A" users = client.get_team_users(1) @@ -467,8 +462,7 @@ def test_cache_save_error_handling(client, tmp_path): ) # Should not crash even if cache saving fails - with pytest.warns(DeprecationWarning, match="get_printers"): - printers = client_bad_cache.get_printers() + printers = client_bad_cache.printers.list_printers() assert len(printers) == 1 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" }, ] From e03a7f09b3b7025af96f9ef9a000aeb4c1559ddd Mon Sep 17 00:00:00 2001 From: Derek Ditch Date: Mon, 23 Feb 2026 00:19:00 +0000 Subject: [PATCH 3/6] docs: update for 1.0.0 stable release - Fix stale API examples in docs/examples.md and docs/sdk/quickstart.md to use the service-based API (client.printers.list_printers(), etc.) following removal of deprecated shim methods - Bump Development Status classifier to 5 - Production/Stable - Complete README with Quick Start snippet, Documentation link, Contributing and License sections - Add CHANGELOG.md (Keep a Changelog format) documenting breaking changes, additions, and alpha release history --- CHANGELOG.md | 60 ++++++++ README.md | 38 ++++- docs/examples.md | 10 +- docs/sdk/quickstart.md | 4 +- pyproject.toml | 6 +- uv.lock | 326 ++++++++++++++++++++--------------------- 6 files changed, 269 insertions(+), 175 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7ef713f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - Unreleased + +### Breaking Changes + +- Removed deprecated shim methods from `PrusaConnectClient` that were present + in the alpha releases. The service-based API is now the sole public interface: + + | Removed method | Replacement | + |---|---| + | `client.get_printers()` | `client.printers.list_printers()` | + | `client.get_printer(uuid)` | `client.printers.get(uuid)` | + | `client.get_cameras()` | `client.cameras.list()` | + | `client.get_teams()` | `client.teams.list_teams()` | + | `client.get_team(id)` | `client.teams.get(id)` | + | `client.send_command(uuid, cmd)` | `client.printers.send_command(uuid, cmd)` | + +### Added + +- **Service-based API:** Resources are now accessed through dedicated service + objects on the client — `client.printers`, `client.cameras`, `client.teams`, + `client.files`, `client.jobs`, and `client.stats`. +- **`prusactl` CLI** with full subcommand coverage: `printer`, `camera`, `team`, + `job`, `file`, `stats`, and `auth`. +- **Statistics service** (`client.stats`) for per-printer material usage, + print time, planned tasks, and job success metrics. +- **Printer command discovery** — `client.printers.get_supported_commands(uuid)` + with optional disk caching and TTL. +- **Validated command execution** — `client.execute_printer_command()` validates + arguments against the printer's reported command schema before sending. +- **Camera WebRTC signaling client** (`PrusaCameraClient`) for pan/tilt control + and image adjustment via the Prusa signaling protocol. +- **G-code metadata parser** (`client.validate_gcode(path)`) for pre-flight + checks before uploading. +- **Persistent credential caching** — CLI credentials are stored in the + platform config directory and auto-loaded by the SDK. + +### Changed + +- CLI rewritten with [Cyclopts](https://github.com/BrianPugh/cyclopts) for + richer help output and `--verbose` / `--debug` global flags. +- Pydantic models split into focused submodules under + `prusa.connect.client.models`. +- `AppConfig` is now fetched at client init time to validate that the server + supports the `PRUSA_AUTH` backend. + +## [1.0.0a2] - 2025-01-13 + +### Changed + +- Refactored CLI to use Cyclopts; split monolithic models into service modules; + added stats commands and documentation site. + +## [1.0.0a0] - Initial alpha release diff --git a/README.md b/README.md index 090519a..5c82d91 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ provides a frictionless, strongly-typed interface for the Prusa Connect API. > > This SDK is not an officially supported or endorsed product of Prusa Research. > It is developed and maintained by an independent developer and is not -> affiliated with Prusa Research. See [Motivation & Design](#motivation-design) +> affiliated with Prusa Research. See [Motivation & Design](#motivation--design) > for more information. **Features:** @@ -20,7 +20,7 @@ provides a frictionless, strongly-typed interface for the Prusa Connect API. - **Strong Typing:** Full Pydantic models for printers, jobs, cameras, and files. - **Batteries Included:** Retries, timeouts, and error handling out of the box. -- **CLI Tool:** Managing printers from the terminal. +- **CLI Tool:** Manage printers from the terminal with `prusactl`. ## Installation @@ -36,6 +36,30 @@ Or install the lightweight library only: pip install prusa-connect-sdk-client ``` +## Quick Start + +```python +from prusa.connect.client import PrusaConnectClient + +# Credentials are automatically loaded from the CLI session +# (run `prusactl auth login` first) +client = PrusaConnectClient() + +for printer in client.printers.list_printers(): + status = printer.printer_state or "UNKNOWN" + print(f"- {printer.name} ({status})") +``` + +Resources are grouped by service — `client.printers`, `client.cameras`, +`client.teams`, `client.files`, `client.jobs`, and `client.stats`. + +## Documentation + +Full documentation including the CLI reference, SDK quickstart, and API +reference is available at: + +**** + ## Motivation & Design My motivation to create this library is to provide a frictionless, @@ -56,3 +80,13 @@ gladly accept Prusameters towards a new Core-generation printer. 😉 My Printables Profile: :simple-printables: [dcode](https://www.printables.com/@dcode_3006269) + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for +development setup, testing, and pull request guidelines. + +## License + +This project is licensed under the +[GNU Affero General Public License v3.0 or later](LICENSE). diff --git a/docs/examples.md b/docs/examples.md index d2b5cb2..0b476b6 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -16,11 +16,11 @@ from prusa.connect.client import PrusaConnectClient client = PrusaConnectClient() -# Get your printer's UUID (e.g., from client.get_printers()) +# Get your printer's UUID (e.g., from client.printers.list_printers()) printer_uuid = "c0ffee-uuid-1234" # Pause the print -client.send_command(printer_uuid, "PAUSE_PRINT") +client.pause_print(printer_uuid) print("Printer paused.") ``` @@ -32,7 +32,7 @@ Fetch the latest snapshot from your printer's camera. from prusa.connect.client import PrusaConnectClient client = PrusaConnectClient() -cameras = client.get_cameras() +cameras = client.cameras.list() if cameras: cam = cameras[0] @@ -54,10 +54,10 @@ List files on your team's storage. from prusa.connect.client import PrusaConnectClient client = PrusaConnectClient() -teams = client.get_teams() +teams = client.teams.list_teams() if teams: my_team_id = teams[0].id - files = client.get_file_list(my_team_id) + files = client.files.list(my_team_id) for file in files: print(f"{file.name} ({file.size.human_readable() if file.size else 'N/A'})") diff --git a/docs/sdk/quickstart.md b/docs/sdk/quickstart.md index c8d96ea..21caf2e 100644 --- a/docs/sdk/quickstart.md +++ b/docs/sdk/quickstart.md @@ -61,7 +61,7 @@ from prusa.connect.client import PrusaConnectClient 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})") @@ -86,7 +86,7 @@ from prusa.connect.client.exceptions import PrusaApiError, PrusaNetworkError client = PrusaConnectClient() try: - printers = client.get_printers() + printers = client.printers.list_printers() except PrusaApiError as e: # HTTP error from the Prusa Connect API (4xx / 5xx) print(f"API error {e.status_code}: {e}") diff --git a/pyproject.toml b/pyproject.toml index 5f5bbef..d5a5ad3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] -name = "prusa-connect-sdk-client" # Normalized per https://packaging.python.org/en/latest/specifications/name-normalization/ +name = "prusa-connect-sdk-client" description = "Unoriginal Prusa Connect API client for Python and CLI" readme = "README.md" authors = [ { name = "Derek Ditch", email = "dcode@users.noreply.github.com"} ] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "Programming Language :: Python :: 3", @@ -108,7 +108,7 @@ packages = ["src/prusa"] [tool.hatch.envs.default] installer = "uv" -env-vars = { "UV_DEFAULT_INDEX" = "https://pypi.org/simple/" } +env-vars = { "UV_DEFAULT_INDEX" = "https://pypi.org/simple" } features = ["cli"] [tool.hatch.build.hooks.protobuf] diff --git a/uv.lock b/uv.lock index a6a936d..42ebcad 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" }, ] From e0f34cad7185d4ca649395db06bed245024dcf94 Mon Sep 17 00:00:00 2001 From: Derek Ditch Date: Mon, 23 Feb 2026 01:56:36 +0000 Subject: [PATCH 4/6] ci: fix pwn-request vulnerability in dependabot lock workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL rule actions/untrusted-checkout/high flagged that the commit job was checking out untrusted PR code inside a pull_request_target workflow (which has secret/write access), violating the principle of least privilege. Fix: split into two workflows following the recommended pattern. - dependabot-uv-lock.yml now uses pull_request (unprivileged, no secrets). It checks out PR code, runs `uv lock`, and uploads the resulting uv.lock as an artifact. No credentials are exposed. - dependabot-uv-lock-commit.yml uses workflow_run, triggered only after the unprivileged workflow succeeds. It checks out the PR branch at the exact HEAD SHA the lock job saw, downloads the artifact into the workspace, and commits. No code from the PR is executed — only git operations on the known-good artifact. The HEAD_BRANCH env-var pattern is used for the push target to avoid shell injection from branch name expressions interpolated directly into the run block. Ref: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ --- .../workflows/dependabot-uv-lock-commit.yml | 50 +++++++++++++++++++ .github/workflows/dependabot-uv-lock.yml | 34 ++----------- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 31 ++++++------ src/prusa/connect/client/__version__.py | 2 +- 5 files changed, 72 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/dependabot-uv-lock-commit.yml diff --git a/.github/workflows/dependabot-uv-lock-commit.yml b/.github/workflows/dependabot-uv-lock-commit.yml new file mode 100644 index 0000000..cc826da --- /dev/null +++ b/.github/workflows/dependabot-uv-lock-commit.yml @@ -0,0 +1,50 @@ +name: "Dependabot: Commit uv.lock" +# Privileged companion to dependabot-uv-lock.yml. +# Triggered only after the unprivileged lock workflow succeeds. Downloads the +# artifact produced there and commits it to the PR branch. No untrusted code +# from the PR is executed here — only git operations on a known-good artifact. +permissions: + contents: read +on: + workflow_run: + workflows: ["Dependabot: Update uv.lock"] + types: [completed] +jobs: + commit: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.actor.login == 'dependabot[bot]' + permissions: + contents: write + steps: + # Checkout by exact SHA so we commit on top of what the lock job saw. + - name: Checkout PR branch at workflow HEAD SHA + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + token: ${{ secrets.DEPENDABOT_PAT }} + # Download after checkout so the artifact lands directly in the workspace, + # overwriting the existing uv.lock with the freshly-generated one. + # Push target is passed via env to avoid shell-injection from branch names. + - name: Download uv.lock artifact + uses: actions/download-artifact@v7 + with: + name: uv-lock + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Commit and push changes + env: + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add uv.lock + + if ! git diff --staged --quiet; then + git commit -m "chore(dependabot): synchronize uv.lock" + git push origin "HEAD:${HEAD_BRANCH}" + else + echo "uv.lock is already up to date." + fi diff --git a/.github/workflows/dependabot-uv-lock.yml b/.github/workflows/dependabot-uv-lock.yml index 75df779..1686568 100644 --- a/.github/workflows/dependabot-uv-lock.yml +++ b/.github/workflows/dependabot-uv-lock.yml @@ -1,8 +1,11 @@ name: "Dependabot: Update uv.lock" +# Runs in the unprivileged pull_request context — no secrets, no write access. +# The generated uv.lock is uploaded as an artifact for the companion +# dependabot-uv-lock-commit workflow to consume once this run completes. permissions: contents: read on: - pull_request_target: + pull_request: types: [opened, synchronize] jobs: lock: @@ -24,32 +27,3 @@ jobs: with: name: uv-lock path: uv.lock - commit: - needs: lock - runs-on: ubuntu-latest - if: github.actor == 'dependabot[bot]' - permissions: - contents: write - steps: - - name: Checkout the PR branch - uses: actions/checkout@v6 - 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]" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git add uv.lock - - if ! git diff --staged --quiet; then - git commit -m "chore(dependabot): synchronize uv.lock" - git push - else - echo "uv.lock is already up to date." - fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50e5432..9b43fc0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,6 +28,6 @@ repos: - id: yamlfmt exclude: ^mkdocs\.yml$ - repo: https://github.com/rhysd/actionlint - rev: v1.7.10 + rev: v1.7.11 hooks: - id: actionlint diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef713f..859a298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,23 +3,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.0.0] - Unreleased ### Breaking Changes -- Removed deprecated shim methods from `PrusaConnectClient` that were present - in the alpha releases. The service-based API is now the sole public interface: +- Removed deprecated shim methods from `PrusaConnectClient` that were present in + the alpha releases. The service-based API is now the sole public interface: - | Removed method | Replacement | - |---|---| - | `client.get_printers()` | `client.printers.list_printers()` | - | `client.get_printer(uuid)` | `client.printers.get(uuid)` | - | `client.get_cameras()` | `client.cameras.list()` | - | `client.get_teams()` | `client.teams.list_teams()` | - | `client.get_team(id)` | `client.teams.get(id)` | - | `client.send_command(uuid, cmd)` | `client.printers.send_command(uuid, cmd)` | + | Removed method | Replacement | + | -------------------------------- | ----------------------------------------- | + | `client.get_printers()` | `client.printers.list_printers()` | + | `client.get_printer(uuid)` | `client.printers.get(uuid)` | + | `client.get_cameras()` | `client.cameras.list()` | + | `client.get_teams()` | `client.teams.list_teams()` | + | `client.get_team(id)` | `client.teams.get(id)` | + | `client.send_command(uuid, cmd)` | `client.printers.send_command(uuid, cmd)` | ### Added @@ -28,8 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `client.files`, `client.jobs`, and `client.stats`. - **`prusactl` CLI** with full subcommand coverage: `printer`, `camera`, `team`, `job`, `file`, `stats`, and `auth`. -- **Statistics service** (`client.stats`) for per-printer material usage, - print time, planned tasks, and job success metrics. +- **Statistics service** (`client.stats`) for per-printer material usage, print + time, planned tasks, and job success metrics. - **Printer command discovery** — `client.printers.get_supported_commands(uuid)` with optional disk caching and TTL. - **Validated command execution** — `client.execute_printer_command()` validates @@ -38,8 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and image adjustment via the Prusa signaling protocol. - **G-code metadata parser** (`client.validate_gcode(path)`) for pre-flight checks before uploading. -- **Persistent credential caching** — CLI credentials are stored in the - platform config directory and auto-loaded by the SDK. +- **Persistent credential caching** — CLI credentials are stored in the platform + config directory and auto-loaded by the SDK. ### Changed diff --git a/src/prusa/connect/client/__version__.py b/src/prusa/connect/client/__version__.py index a3d76cc..7772031 100644 --- a/src/prusa/connect/client/__version__.py +++ b/src/prusa/connect/client/__version__.py @@ -9,4 +9,4 @@ ``` """ -__version__ = "1.0.0a2" +__version__ = "1.0.0" From 3ba044d9c5a2d4015d0dd850bbbb92764650d234 Mon Sep 17 00:00:00 2001 From: Derek Ditch Date: Tue, 24 Feb 2026 03:36:10 +0000 Subject: [PATCH 5/6] chore: Final polish for 1.0.0 release - Adds `--format` flag to CLI to allow selection of rich, plain, or json output - Change print usage stats value type to duration with human-readable output or optional `--seconds` flag for numeric output --- .../workflows/dependabot-uv-lock-commit.yml | 4 +- CHANGELOG.md | 14 +- docs/cli/quickstart.md | 26 +- src/prusa/connect/client/cli/commands/api.py | 13 +- src/prusa/connect/client/cli/commands/auth.py | 119 +++-- .../connect/client/cli/commands/camera.py | 155 ++++--- src/prusa/connect/client/cli/commands/file.py | 82 ++-- src/prusa/connect/client/cli/commands/job.py | 126 +++-- .../connect/client/cli/commands/printer.py | 431 ++++++++---------- .../connect/client/cli/commands/stats.py | 113 +++-- src/prusa/connect/client/cli/commands/team.py | 103 ++--- src/prusa/connect/client/cli/common.py | 110 ++++- src/prusa/connect/client/cli/config.py | 17 + src/prusa/connect/client/cli/main.py | 12 +- src/prusa/connect/client/models/stats.py | 2 +- tests/unit_tests/test_cli_output.py | 109 +++++ tests/unit_tests/test_stats.py | 2 +- 17 files changed, 848 insertions(+), 590 deletions(-) create mode 100644 tests/unit_tests/test_cli_output.py diff --git a/.github/workflows/dependabot-uv-lock-commit.yml b/.github/workflows/dependabot-uv-lock-commit.yml index cc826da..7d2de5f 100644 --- a/.github/workflows/dependabot-uv-lock-commit.yml +++ b/.github/workflows/dependabot-uv-lock-commit.yml @@ -13,8 +13,8 @@ jobs: commit: runs-on: ubuntu-latest if: > - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.actor.login == 'dependabot[bot]' + github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.actor.login == 'dependabot[bot]' + permissions: contents: write steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 859a298..cc2d69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] - Unreleased +## [1.0.0] - 2026-02-23 ### Breaking Changes @@ -35,12 +35,20 @@ and this project adheres to with optional disk caching and TTL. - **Validated command execution** — `client.execute_printer_command()` validates arguments against the printer's reported command schema before sending. -- **Camera WebRTC signaling client** (`PrusaCameraClient`) for pan/tilt control - and image adjustment via the Prusa signaling protocol. +- **Camera Signal.IO signaling client** (`PrusaCameraClient`) for pan/tilt + control and image adjustment via the Prusa signaling protocol. - **G-code metadata parser** (`client.validate_gcode(path)`) for pre-flight checks before uploading. - **Persistent credential caching** — CLI credentials are stored in the platform config directory and auto-loaded by the SDK. +- **CLI output format control** — the `--format` global flag selects between + `rich` (coloured tables; default when stdout is a TTY), `plain` + (tab-separated text with no ANSI escapes or table borders; default when + stdout is not a TTY), and `json` (JSON array per table to stdout, all status + messages to stderr). +- The active format can also be set via the `output_format` key in `config.json` + or the `PRUSACTL_OUTPUT_FORMAT` environment variable; the priority is: CLI + flag → env var → config file → TTY auto-detect. ### Changed diff --git a/docs/cli/quickstart.md b/docs/cli/quickstart.md index f21b34e..5f1a583 100644 --- a/docs/cli/quickstart.md +++ b/docs/cli/quickstart.md @@ -111,6 +111,23 @@ prusactl printer --help prusactl stats --help ``` +## Step 10: Change Output Format + +By default, `prusactl` uses `rich` output (colored tables and text) when running +in a terminal, and `plain` output (tab-separated) when piped or redirected. You +can explicitly set the output format using the `--format` flag: + +```bash +# JSON output (useful for jq or other scripts) +prusactl printer list --format json + +# Plain text (tab-separated) +prusactl printer list --format plain + +# Force rich output even when redirected +prusactl printer list --format rich +``` + ## Configuration File Settings like default printer, team, and camera IDs are stored in a JSON file in @@ -128,8 +145,13 @@ You can edit this file directly. Supported keys: { "default_printer_id": "your-printer-uuid", "default_team_id": 12345, - "default_camera_id": "your-camera-id" + "default_camera_id": "your-camera-id", + "output_format": "json" } ``` -Environment variables (e.g. `DEFAULT_PRINTER_ID`) override file values. +Environment variables override file values: + +- `PRUSACTL_OUTPUT_FORMAT`: Set to `rich`, `plain`, or `json`. +- `DEFAULT_PRINTER_ID`: Override the default printer UUID. +- `DEFAULT_TEAM_ID`: Override the default team ID. diff --git a/src/prusa/connect/client/cli/commands/api.py b/src/prusa/connect/client/cli/commands/api.py index 613edce..f69d420 100644 --- a/src/prusa/connect/client/cli/commands/api.py +++ b/src/prusa/connect/client/cli/commands/api.py @@ -9,7 +9,6 @@ import cyclopts import requests # noqa: TC002 -from rich import print as rprint from prusa.connect.client.cli import common @@ -49,10 +48,10 @@ def api_command( res: requests.Response = client._request(method, path, raw=True, **kwargs) if response_headers: - rprint(f"{getattr(res, 'status_code', None)} {getattr(res, 'reason', None)}") + print(f"{getattr(res, 'status_code', None)} {getattr(res, 'reason', None)}") for k, v in res.headers.items(): - rprint(f"[bold]{k}:[/bold] {v}") - rprint("") + print(f"{k}: {v}") + print("") if stream: # Handle streaming @@ -60,7 +59,7 @@ def api_command( with open(output, "wb") as f: for chunk in res.iter_content(chunk_size=8192): f.write(chunk) - rprint(f"[green]Streamed response to {output}[/green]") + common.output_message(f"Streamed response to {output}") else: # Stream to stdout for chunk in res.iter_content(chunk_size=8192): @@ -88,7 +87,7 @@ def api_command( f.write(res.text) else: output.write_bytes(res.content) - rprint(f"[green]Response saved to {output}[/green]") + common.output_message(f"Response saved to {output}") else: if response_body: if "application/json" in content_type.lower(): @@ -103,4 +102,4 @@ def api_command( # If piping or streaming, print error to stderr sys.stderr.write(f"API Request Failed: {e}\n") else: - rprint(f"[red]API Request Failed: {e}[/red]") + common.output_message(f"API Request Failed: {e}", error=True) diff --git a/src/prusa/connect/client/cli/commands/auth.py b/src/prusa/connect/client/cli/commands/auth.py index 90542af..ae43e9f 100644 --- a/src/prusa/connect/client/cli/commands/auth.py +++ b/src/prusa/connect/client/cli/commands/auth.py @@ -8,9 +8,7 @@ import typing 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 @@ -21,14 +19,15 @@ @auth_app.command(name="login") def login_command(): """Perform interactive login.""" - rprint("[bold blue]Logging in to Prusa Connect...[/bold blue]") + # Always use rich console for interactive prompts regardless of format + common.console.print("[bold blue]Logging in to Prusa Connect...[/bold blue]") default_email = config.settings.prusa_email or os.environ.get("PRUSA_EMAIL") email = Prompt.ask("Email", default=default_email) default_password = config.settings.prusa_password or os.environ.get("PRUSA_PASSWORD") if default_password: - rprint("[dim]Using password from environment/config[/dim]") + common.console.print("[dim]Using password from environment/config[/dim]") password = default_password else: password = Prompt.ask("Password", password=True) @@ -41,19 +40,17 @@ def otp_callback() -> str: def save_tokens(data): path = config.settings.tokens_file - # Ensure parent dir exists path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w") as f: json.dump(data, f, indent=2) - # Best-effort secure permissions with contextlib.suppress(OSError): os.chmod(path, 0o600) save_tokens(token_data.dump_tokens()) - rprint(f"[green]Authentication successful! Tokens saved to {config.settings.tokens_file}[/green]") + common.output_message(f"Authentication successful! Tokens saved to {config.settings.tokens_file}") except Exception as e: - rprint(f"[bold red]Authentication failed: {e}[/bold red]") + common.output_message(f"Authentication failed: {e}", error=True) sys.exit(1) @@ -66,42 +63,81 @@ def show_command(): if creds: creds.refresh() except exceptions.PrusaAuthError: - rprint("[yellow]Not authenticated or tokens expired.[/yellow]") + common.output_message("Not authenticated or tokens expired.") return if not creds or not creds.tokens: - rprint("[yellow]No tokens found.[/yellow]") + common.output_message("No tokens found.") return t = creds.tokens - table = Table(title="Authentication Status", show_header=False) - # helper def fmt_ts(ts): return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") - if t.identity_token: - table.add_row("[bold]Identity[/bold]", "") - table.add_row(" Subject (Sub)", str(t.identity_token.user_id)) - table.add_row(" Issuer (Iss)", t.identity_token.issuer) - table.add_row(" Token ID (JTI)", t.identity_token.token_id) - if t.identity_token.user_info: - for k, v in t.identity_token.user_info.items(): - if v: - table.add_row(f" User.{k}", str(v)) - - if t.access_token: - table.add_row("[bold]Access Token[/bold]", "") - table.add_row(" Token ID (JTI)", t.access_token.token_id) - table.add_row(" Expires", fmt_ts(t.access_token.expires_at.timestamp())) - table.add_row(" Scope", ", ".join(t.scope)) - - if t.refresh_token: - table.add_row("[bold]Refresh Token[/bold]", "") - table.add_row(" Token ID (JTI)", t.refresh_token.token_id) - table.add_row(" Expires", fmt_ts(t.refresh_token.expires_at.timestamp())) - - common.console.print(table) + fmt = common.get_output_format() + + if fmt == "json": + # Serialize structured token data + data: dict[str, dict[str, str | list[str] | dict[str, str]]] = {} + if t.identity_token: + identity: dict[str, str | list[str] | dict[str, str]] = { + "user_id": str(t.identity_token.user_id), + "issuer": t.identity_token.issuer, + "token_id": t.identity_token.token_id, + } + if t.identity_token.user_info: + identity["user_info"] = {k: str(v) for k, v in t.identity_token.user_info.items() if v} + data["identity"] = identity + if t.access_token: + data["access_token"] = { + "token_id": t.access_token.token_id, + "expires": fmt_ts(t.access_token.expires_at.timestamp()), + "scope": list(t.scope), + } + if t.refresh_token: + data["refresh_token"] = { + "token_id": t.refresh_token.token_id, + "expires": fmt_ts(t.refresh_token.expires_at.timestamp()), + } + print(json.dumps(data)) + else: + rows: list[list[str]] = [] + + if t.identity_token: + rows.append(["Identity", ""]) + rows.append([" Subject (Sub)", str(t.identity_token.user_id)]) + rows.append([" Issuer (Iss)", t.identity_token.issuer]) + rows.append([" Token ID (JTI)", t.identity_token.token_id]) + if t.identity_token.user_info: + for k, v in t.identity_token.user_info.items(): + if v: + rows.append([f" User.{k}", str(v)]) + + if t.access_token: + rows.append(["Access Token", ""]) + rows.append([" Token ID (JTI)", t.access_token.token_id]) + rows.append([" Expires", fmt_ts(t.access_token.expires_at.timestamp())]) + rows.append([" Scope", ", ".join(t.scope)]) + + if t.refresh_token: + rows.append(["Refresh Token", ""]) + rows.append([" Token ID (JTI)", t.refresh_token.token_id]) + rows.append([" Expires", fmt_ts(t.refresh_token.expires_at.timestamp())]) + + if fmt == "plain": + print("# Authentication Status") + for label, value in rows: + print(f"{label}\t{value}") + else: + from rich.table import Table + + table = Table(title="Authentication Status", show_header=False) + table.add_column("Key", style="bold") + table.add_column("Value") + for label, value in rows: + table.add_row(label, value) + common.console.print(table) @auth_app.command(name="clear") @@ -110,24 +146,23 @@ def clear_command(): path = config.settings.tokens_file if path.exists(): if not Confirm.ask(f"Clear saved credentials at {path}?"): - rprint("[dim]Aborted.[/dim]") + common.output_message("Aborted.") return path.unlink() - rprint(f"[green]Removed tokens file: {path}[/green]") + common.output_message(f"Removed tokens file: {path}") else: - rprint(f"[yellow]No tokens file found at {path}[/yellow]") + common.output_message(f"No tokens file found at {path}") 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: with contextlib.suppress(exceptions.PrusaAuthError): creds.refresh() if not creds or not creds.tokens: - rprint("[red]No credentials found.[/red]", file=sys.stderr) + common.output_message("No credentials found.", error=True) sys.exit(1) token = None @@ -139,7 +174,7 @@ def _print_token(kind: typing.Literal["access", "identity"]): if token: print(token) else: - rprint(f"[red]No {kind} token found.[/red]", file=sys.stderr) + common.output_message(f"No {kind} token found.", error=True) sys.exit(1) @@ -153,7 +188,3 @@ def print_access_token_command(): 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 614bbb0..e1b754c 100644 --- a/src/prusa/connect/client/cli/commands/camera.py +++ b/src/prusa/connect/client/cli/commands/camera.py @@ -5,13 +5,17 @@ import typing import cyclopts -from rich import print as rprint -from rich.table import Table from prusa.connect.client.cli import common, config camera_app = cyclopts.App(name="camera", help="Camera management") +_NO_CAMERA = ( + "No camera ID provided and no default configured.\n" + "Hint: Run 'prusactl camera list' to find a numeric ID, then " + "'prusactl camera set-current ' to set the default." +) + @camera_app.command(name="list") def camera_list(): @@ -20,41 +24,39 @@ def camera_list(): client = common.get_client() cameras = client.cameras.list() - table = Table(title="Cameras") - table.add_column("Name", style="cyan") - table.add_column("ID (Numeric)", style="magenta") - table.add_column("Token", style="green") - table.add_column("Origin", style="blue") - - for c in cameras: - table.add_row( - c.name or "Unknown", - str(c.id) if c.id else "N/A", - c.token or "N/A", - c.origin or "N/A", - ) - common.console.print(table) + rows = [[c.name or "Unknown", str(c.id) if c.id else "N/A", c.token or "N/A", c.origin or "N/A"] for c in cameras] + common.output_table( + "Cameras", + ["Name", "ID (Numeric)", "Token", "Origin"], + rows, + column_styles=["cyan", "magenta", "green", "blue"], + ) -def camera_alias(): +def cameras_alias(): """List all cameras (alias for 'camera list').""" camera_list() @camera_app.command(name="snapshot") def camera_snapshot( - camera_id: typing.Annotated[str, cyclopts.Parameter(help="Camera ID (Numeric)")], + camera_id: typing.Annotated[str | None, cyclopts.Parameter(help="Camera ID (Numeric)")] = None, output: typing.Annotated[pathlib.Path | None, cyclopts.Parameter(help="Output file path for snapshot")] = None, ): """Take a snapshot from a camera.""" common.logger.debug("Command started", command="camera snapshot", camera_id=camera_id, output=output) client = common.get_client() + resolved_id = camera_id or config.settings.default_camera_id + if not resolved_id: + common.output_message(_NO_CAMERA, error=True) + return + # We look up the camera to get ID 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 + real_id = resolved_id if match: if match.id: real_id = str(match.id) @@ -67,29 +69,36 @@ def camera_snapshot( sys.stdout.buffer.write(data) else: output.write_bytes(data) - rprint(f"[green]Saved snapshot to {output}[/green]") + common.output_message(f"Saved snapshot to {output}") else: - rprint(f"[green]Snapshot received: {len(data)} bytes[/green]") + common.output_message(f"Snapshot received: {len(data)} bytes") except Exception as e: if str(output) == "-": sys.stderr.write(f"Failed to get snapshot: {e}\n") else: - rprint(f"[red]Failed to get snapshot: {e}[/red]") + common.output_message(f"Failed to get snapshot: {e}", error=True) @camera_app.command(name="trigger") def camera_trigger( - camera_id: typing.Annotated[str, cyclopts.Parameter(help="Camera Token or ID (if mapped)")], + camera_id: typing.Annotated[str | None, cyclopts.Parameter(help="Camera Token or ID (if mapped)")] = None, ): """Trigger a snapshot on a camera.""" common.logger.debug("Command started", command="camera trigger", camera_id=camera_id) client = common.get_client() + resolved_id = camera_id or config.settings.default_camera_id + if not resolved_id: + common.output_message(_NO_CAMERA, error=True) + return + # We look up to get token 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) + match = next( + (c for c in cameras if str(c.id) == resolved_id or c.token == resolved_id or c.name == resolved_id), None + ) - real_token = camera_id + real_token = resolved_id if match: if match.token: real_token = match.token @@ -97,39 +106,52 @@ def camera_trigger( try: if client.trigger_snapshot(real_token): - rprint(f"[green]Triggered snapshot for {camera_id}[/green]") + common.output_message(f"Triggered snapshot for {camera_id}") except Exception as e: - rprint(f"[red]Failed to trigger snapshot: {e}[/red]") + common.output_message(f"Failed to trigger snapshot: {e}", error=True) @camera_app.command(name="move") def camera_move( - camera_id: typing.Annotated[str, cyclopts.Parameter(help="Camera Token or ID")], - direction: typing.Annotated[str, cyclopts.Parameter(help="Direction: LEFT, RIGHT, UP, DOWN")], + camera_id: typing.Annotated[str | None, cyclopts.Parameter(help="Camera Token or ID")] = None, + direction: typing.Annotated[ + typing.Literal["LEFT", "RIGHT", "UP", "DOWN"] | None, + cyclopts.Parameter(help="Direction: LEFT, RIGHT, UP, DOWN"), + ] = None, angle: typing.Annotated[int, cyclopts.Parameter(help="Angle in degrees")] = 30, ): """Move a pan/tilt camera.""" common.logger.debug("Command started", command="camera move", camera_id=camera_id, direction=direction) client = common.get_client() + resolved_id = camera_id or config.settings.default_camera_id + if not resolved_id: + common.output_message(_NO_CAMERA, error=True) + return + + if direction is None: + common.output_message("A direction (LEFT, RIGHT, UP, or DOWN) must be specified", error=True) + # Resolve token 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 + match = next( + (c for c in cameras if str(c.id) == resolved_id or c.token == resolved_id or c.name == resolved_id), None + ) + token = match.token if match and match.token else resolved_id try: cam_client = client.get_camera_client(token) cam_client.connect() - cam_client.move(direction, angle) - rprint(f"[green]Sent {direction} move command to {camera_id}[/green]") + cam_client.move(str(direction), angle) + common.output_message(f"Sent {direction} move command to {camera_id}") cam_client.disconnect() except Exception as e: - rprint(f"[red]Failed to move camera: {e}[/red]") + common.output_message(f"Failed to move camera: {e}", error=True) @camera_app.command(name="adjust") def camera_adjust( - camera_id: typing.Annotated[str, cyclopts.Parameter(help="Camera Token or ID")], + camera_id: typing.Annotated[str | None, cyclopts.Parameter(help="Camera Token or ID")] = None, brightness: typing.Annotated[int | None, cyclopts.Parameter(help="Brightness value")] = None, contrast: typing.Annotated[int | None, cyclopts.Parameter(help="Contrast value")] = None, saturation: typing.Annotated[int | None, cyclopts.Parameter(help="Saturation value")] = None, @@ -138,10 +160,17 @@ def camera_adjust( common.logger.debug("Command started", command="camera adjust", camera_id=camera_id) client = common.get_client() + resolved_id = camera_id or config.settings.default_camera_id + if not resolved_id: + common.output_message(_NO_CAMERA, error=True) + return + # Resolve token 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 + match = next( + (c for c in cameras if str(c.id) == resolved_id or c.token == resolved_id or c.name == resolved_id), None + ) + token = match.token if match and match.token else resolved_id kwargs = {} if brightness is not None: @@ -152,17 +181,17 @@ def camera_adjust( kwargs["saturation"] = saturation if not kwargs: - rprint("[yellow]No adjustments provided. Use --brightness, --contrast, or --saturation.[/yellow]") + common.output_message("No adjustments provided. Use --brightness, --contrast, or --saturation.") return try: cam_client = client.get_camera_client(token) cam_client.connect() cam_client.adjust(**kwargs) - rprint(f"[green]Adjusted settings for {camera_id}[/green]") + common.output_message(f"Adjusted settings for {camera_id}") cam_client.disconnect() except Exception as e: - rprint(f"[red]Failed to adjust camera: {e}[/red]") + common.output_message(f"Failed to adjust camera: {e}", error=True) @camera_app.command(name="set-current") @@ -170,49 +199,59 @@ def set_current_camera(camera_id: typing.Annotated[str, cyclopts.Parameter(help= """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]") + common.output_message(f"Successfully set default camera to {camera_id}") @camera_app.command(name="show") def camera_show( - camera_id: typing.Annotated[str, cyclopts.Parameter(help="Camera Token or ID or Name")], + camera_id: typing.Annotated[str | None, cyclopts.Parameter(help="Camera Token or ID or Name")] = None, 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() + resolved_id = camera_id or config.settings.default_camera_id + if not resolved_id: + common.output_message(_NO_CAMERA, error=True) + return + 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) + match = next( + (c for c in cameras if str(c.id) == resolved_id or c.token == resolved_id or c.name == resolved_id), None + ) if not match: - rprint(f"[red]Camera '{camera_id}' not found.[/red]") + common.output_message(f"Camera '{camera_id}' not found.", error=True) sys.exit(1) - if detailed: + if detailed and common.get_output_format() == "rich": 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") + rows = [ + ["Name", match.name or "N/A"], + ["ID (Numeric)", str(match.id) if match.id else "N/A"], + ["Token", match.token or "N/A"], + ["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}") + rows.append(["Resolution", f"{match.config.resolution.width}x{match.config.resolution.height}"]) if match.config.firmware: - table.add_row("Firmware", match.config.firmware) + rows.append(["Firmware", match.config.firmware]) if match.config.model: - table.add_row("Model", match.config.model) + rows.append(["Model", match.config.model]) if match.printer_uuid: - table.add_row("Printer UUID", match.printer_uuid) + rows.append(["Printer UUID", match.printer_uuid]) - common.console.print(table) + common.output_table( + f"Camera: {match.name or 'Unknown'}", + ["Property", "Value"], + rows, + column_styles=["bold cyan", None], + ) diff --git a/src/prusa/connect/client/cli/commands/file.py b/src/prusa/connect/client/cli/commands/file.py index 8e47132..127d166 100644 --- a/src/prusa/connect/client/cli/commands/file.py +++ b/src/prusa/connect/client/cli/commands/file.py @@ -1,10 +1,10 @@ """File management commands.""" +import json import os import typing import cyclopts -from rich.table import Table from prusa.connect.client.cli import common, config @@ -22,26 +22,24 @@ def file_list( if not resolved_team_id: teams = client.teams.list_teams() if not teams: - common.console.print("[red]No teams found.[/red]") + common.output_message("No teams found.", error=True) return resolved_team_id = teams[0].id - common.console.print( - f"[yellow]No team ID provided. Using first team: {teams[0].name} ({resolved_team_id})[/yellow]" - ) + common.output_message(f"No team ID provided. Using first team: {teams[0].name} ({resolved_team_id})") files = client.get_file_list(resolved_team_id) - table = Table(title=f"Files for Team {resolved_team_id}") - table.add_column("Name", style="cyan") - table.add_column("Type", style="green") - table.add_column("Size", style="magenta") - table.add_column("Hash", style="blue") - + rows = [] for f in files: size_str = f"{f.size.human_readable()}" if f.size else "N/A" - table.add_row(f.name or "N/A", f.type or "N/A", size_str, f.hash or "N/A") + rows.append([f.name or "N/A", f.type or "N/A", size_str, f.hash or "N/A"]) - common.console.print(table) + common.output_table( + f"Files for Team {resolved_team_id}", + ["Name", "Type", "Size", "Hash"], + rows, + column_styles=["cyan", "green", "magenta", "blue"], + ) def files_alias( @@ -63,23 +61,23 @@ def file_upload( if not resolved_team_id: teams = client.teams.list_teams() if not teams: - common.console.print("[red]No teams found.[/red]") + common.output_message("No teams found.", error=True) return resolved_team_id = teams[0].id file_path = os.path.abspath(path) if not os.path.exists(file_path): - common.console.print(f"[red]File not found: {path}[/red]") + common.output_message(f"File not found: {path}", error=True) return filename = os.path.basename(file_path) size = os.path.getsize(file_path) - common.console.print(f"Initiating upload for [cyan]{filename}[/cyan] ({size} bytes)...") + common.output_message(f"Initiating upload for {filename} ({size} bytes)...") try: status = client.initiate_team_upload(resolved_team_id, destination, filename, size) upload_id = status.id - common.console.print(f"Upload initiated. ID: [magenta]{upload_id}[/magenta]. Uploading data...") + common.output_message(f"Upload initiated. ID: {upload_id}. Uploading data...") with open(file_path, "rb") as f: data = f.read() @@ -91,9 +89,9 @@ def file_upload( content_type = "text/x.gcode" client.upload_team_file(resolved_team_id, upload_id, data, content_type=content_type) - common.console.print("[green]Upload successful![/green]") + common.output_message("Upload successful!") except Exception as e: - common.console.print(f"[red]Upload failed: {e}[/red]") + common.output_message(f"Upload failed: {e}", error=True) @file_app.command(name="download") @@ -108,11 +106,11 @@ def file_download( if not resolved_team_id: teams = client.teams.list_teams() if not teams: - common.console.print("[red]No teams found.[/red]") + common.output_message("No teams found.", error=True) return resolved_team_id = teams[0].id - common.console.print(f"Downloading file with hash [cyan]{file_hash}[/cyan]...") + common.output_message(f"Downloading file with hash {file_hash}...") try: data = client.download_team_file(resolved_team_id, file_hash) @@ -120,9 +118,9 @@ def file_download( with open(dest_path, "wb") as f: f.write(data) - common.console.print(f"[green]Downloaded to {dest_path}[/green]") + common.output_message(f"Downloaded to {dest_path}") except Exception as e: - common.console.print(f"[red]Download failed: {e}[/red]") + common.output_message(f"Download failed: {e}", error=True) @file_app.command(name="show") @@ -139,38 +137,40 @@ def file_show( if not resolved_team_id: teams = client.teams.list_teams() if not teams: - common.console.print("[red]No teams found.[/red]") + common.output_message("No teams found.", error=True) return resolved_team_id = teams[0].id try: file = client.get_team_file(resolved_team_id, file_hash) - table = Table(title=f"File Details: {file.name}") - table.add_column("Property", style="cyan") - table.add_column("Value", style="green") - - table.add_row("Name", file.name) - table.add_row("Type", getattr(file, "type", "N/A")) + rows: list[list[str]] = [["Name", file.name], ["Type", getattr(file, "type", "N/A")]] if getattr(file, "size", None) is not None: size_val = typing.cast("int", file.size) - size_str = f"{size_val / 1024 / 1024:.2f} MB" - table.add_row("Size", size_str) + rows.append(["Size", f"{size_val / 1024 / 1024:.2f} MB"]) if getattr(file, "hash", None): - table.add_row("Hash", file.hash) + rows.append(["Hash", file.hash]) - common.console.print(table) + common.output_table( + f"File Details: {file.name}", + ["Property", "Value"], + rows, + column_styles=["cyan", "green"], + ) if detailed: - import json - - common.console.print("\n[bold]Detailed Information:[/bold]") - detail_table = Table(show_header=False, box=None) + detail_rows = [] for k, v in file.model_dump(mode="json").items(): if v is not None and k not in ["name", "type", "size", "hash"]: val_str = json.dumps(v) if isinstance(v, (dict, list)) else str(v) - detail_table.add_row(f"[cyan]{k.title().replace('_', ' ')}[/cyan]:", val_str) - common.console.print(detail_table) + detail_rows.append([k.title().replace("_", " "), val_str]) + if detail_rows: + common.output_table( + "Detailed Information", + ["Field", "Value"], + detail_rows, + column_styles=["cyan", None], + ) except Exception as e: - common.console.print(f"[red]Failed to fetch file details: {e}[/red]") + common.output_message(f"Failed to fetch file details: {e}", error=True) diff --git a/src/prusa/connect/client/cli/commands/job.py b/src/prusa/connect/client/cli/commands/job.py index b70910f..808bed8 100644 --- a/src/prusa/connect/client/cli/commands/job.py +++ b/src/prusa/connect/client/cli/commands/job.py @@ -4,8 +4,6 @@ import typing import cyclopts -from rich import print as rprint -from rich.table import Table from prusa.connect.client.cli import common, config @@ -38,15 +36,12 @@ def job_list( all_jobs.extend(client.get_team_jobs(team, state=state, limit=limit)) else: # Aggregation mode: Get jobs from ALL printers (cached) - # This is preferred over iterating teams if we want "my printers" context try: printers = client.printers.list_printers() for p in printers: if not p.uuid: continue try: - # We fetch 'limit' items from EACH printer to ensure we have enough candidates for global sort - # If limit is None, we fetch default page p_jobs = client.get_printer_jobs(p.uuid, state=state, limit=limit) all_jobs.extend(p_jobs) except Exception as e: @@ -55,44 +50,39 @@ def job_list( common.logger.error("Failed to fetch printer list for aggregation", error=str(e)) # Sort by 'end' time (descending) to show most recent first - # Fallback to 'start' or 'id' if 'end' is missing def sort_key(j): - # We want descending order, so we return a tuple that compares correctly - # Use 0 as fallback for timestamps if missing return (j.end or 0, j.start or 0, j.id or 0) all_jobs.sort(key=sort_key, reverse=True) - # Apply global limit if limit is not None: all_jobs = all_jobs[:limit] - table = Table(title="Jobs") - table.add_column("ID", style="cyan") - table.add_column("Printer", style="magenta") - table.add_column("State", style="green") - table.add_column("Name", style="blue") - table.add_column("Progress", style="yellow") - table.add_column("Ended", style="dim") - + rows = [] for j in all_jobs: - # Format timestamp ended_str = "N/A" if j.end: ended_str = datetime.datetime.fromtimestamp(j.end).strftime("%Y-%m-%d %H:%M") elif j.state == "PRINTING": ended_str = "In Progress" - table.add_row( - str(j.id), - j.printer_uuid 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, + rows.append( + [ + str(j.id), + j.printer_uuid 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, + ] ) - common.console.print(table) + common.output_table( + "Jobs", + ["ID", "Printer", "State", "Name", "Progress", "Ended"], + rows, + column_styles=["cyan", "magenta", "green", "blue", "yellow", "dim"], + ) def jobs_alias( @@ -119,7 +109,7 @@ def job_queued( try: all_jobs.extend(client.get_printer_queue(printer)) except Exception as e: - rprint(f"[red]Failed to fetch queue for {printer}: {e}[/red]") + common.output_message(f"Failed to fetch queue for {printer}: {e}", error=True) else: # Aggregate from all printers try: @@ -133,31 +123,26 @@ def job_queued( except Exception as e: common.logger.warning(f"Failed to fetch queue for printer {p.name}", error=str(e)) except Exception as e: - rprint(f"[red]Failed to fetch printer list: {e}[/red]") - - # Sort by creation/id (ascending for queue usually? or purely by order returned?) - # Usually queues are FIFO, but aggregation might mix them. - # We'll trust the order or sort by ID/date if available. - # For now, let's keep them somewhat creation-ordered if possible. + common.output_message(f"Failed to fetch printer list: {e}", error=True) - table = Table(title="Job Queue") - table.add_column("ID", style="cyan") - table.add_column("Printer", style="magenta") - table.add_column("State", style="green") - table.add_column("Name", style="blue") - table.add_column("Source", style="dim") + if not all_jobs: + common.output_message("No jobs in queue.") + return + rows = [] for j in all_jobs: source = "Unknown" if j.source_info: source = j.source_info.public_name or j.source_info.first_name or "Unknown" - table.add_row(str(j.id), j.printer_uuid or "Unknown", j.state, j.file.name if j.file else "Unknown", source) + rows.append([str(j.id), j.printer_uuid or "Unknown", j.state, j.file.name if j.file else "Unknown", source]) - if not all_jobs: - rprint("[yellow]No jobs in queue.[/yellow]") - else: - common.console.print(table) + common.output_table( + "Job Queue", + ["ID", "Printer", "State", "Name", "Source"], + rows, + column_styles=["cyan", "magenta", "green", "blue", "dim"], + ) @job_app.command(name="show") @@ -173,40 +158,42 @@ def job_show( client = common.get_client() resolved_printer_id = printer or config.settings.default_printer_id if not resolved_printer_id: - rprint("[red]Printer UUID is required. Provide --printer or set a default.[/red]") + common.output_message("Printer UUID is required. Provide --printer or set a default.", error=True) return try: job = client.get_job(resolved_printer_id, job_id) - # Basic Info - rprint(f"[bold cyan]Job {job.id}[/bold cyan]") - rprint(f"State: [green]{job.state}[/green]") + rows = [["ID", str(job.id)], ["State", str(job.state)]] if job.file: - rprint(f"File: {job.file.name}") + rows.append(["File", job.file.name]) if job.progress is not None: - rprint(f"Progress: {job.progress}%") + rows.append(["Progress", f"{job.progress}%"]) if job.time_printing: - rprint(f"Time Printing: {job.time_printing}s") + rows.append(["Time Printing", str(job.time_printing)]) + + common.output_table( + f"Job {job.id}", + ["Field", "Value"], + rows, + column_styles=["cyan", None], + ) # Cancelable Objects if job.cancelable_objects: - rprint("\n[bold]Cancelable Objects:[/bold]") - table = Table(show_header=True, header_style="bold magenta") - table.add_column("ID", style="cyan", justify="right") - table.add_column("Name", style="white") - - for obj in job.cancelable_objects: - table.add_row(str(obj.id), obj.name) - - common.console.print(table) + obj_rows = [[str(obj.id), obj.name] for obj in job.cancelable_objects] + common.output_table( + "Cancelable Objects", + ["ID", "Name"], + obj_rows, + column_styles=["cyan", "white"], + ) else: - rprint("\n[dim]No cancelable objects found for this job.[/dim]") + common.output_message("No cancelable objects found for this job.") if detailed: import json - rprint("\n[bold]Detailed Information:[/bold]") - detail_table = Table(show_header=False, box=None) + detail_rows = [] for k, v in job.model_dump(mode="json").items(): if v is not None and k not in [ "id", @@ -217,8 +204,15 @@ def job_show( "cancelable_objects", ]: val_str = json.dumps(v) if isinstance(v, (dict, list)) else str(v) - detail_table.add_row(f"[cyan]{k.title().replace('_', ' ')}[/cyan]:", val_str) - common.console.print(detail_table) + detail_rows.append([k.title().replace("_", " "), val_str]) + + if detail_rows: + common.output_table( + "Detailed Information", + ["Field", "Value"], + detail_rows, + column_styles=["cyan", None], + ) except Exception as e: - rprint(f"[red]Failed to fetch job details: {e}[/red]") + common.output_message(f"Failed to fetch job details: {e}", error=True) diff --git a/src/prusa/connect/client/cli/commands/printer.py b/src/prusa/connect/client/cli/commands/printer.py index 8c88e20..3e85ed3 100644 --- a/src/prusa/connect/client/cli/commands/printer.py +++ b/src/prusa/connect/client/cli/commands/printer.py @@ -6,8 +6,6 @@ import typing import cyclopts -from rich import print as rprint -from rich.table import Table from prusa.connect.client import exceptions, models from prusa.connect.client.cli import common, config @@ -16,6 +14,12 @@ files_printer_app = cyclopts.App(name="files", help="Printer file management") printer_app.command(files_printer_app) +_NO_PRINTER = ( + "No printer ID provided and no default configured.\n" + "Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default." +) + def _send_printer_command(printer_ids: list[str], command: str): """Helper to send a command to multiple printers.""" @@ -24,9 +28,9 @@ def _send_printer_command(printer_ids: list[str], command: str): for pid in printer_ids: try: if client.printers.send_command(pid, command): - rprint(f"[green]Sent {command} to {pid}[/green]") + common.output_message(f"Sent {command} to {pid}") except Exception as e: - rprint(f"[red]Failed to send {command} to {pid}: {e}[/red]") + common.output_message(f"Failed to send {command} to {pid}: {e}", error=True) @printer_app.command(name="list") @@ -39,25 +43,20 @@ def printer_list( printers = client.printers.list_printers() common.logger.info("Found printers", count=len(printers)) - table = Table(title="Printers") - table.add_column("Name", style="cyan") - table.add_column("UUID", style="magenta") - table.add_column("State", style="green") - table.add_column("Model", style="blue") - - # Filter filtered = [p for p in printers if fnmatch.fnmatch(p.name or "", pattern)] + rows = [] for p in filtered: common.logger.debug("Printer", json=p.model_dump_json()) state_str = str(p.printer_state) if p.printer_state else "UNKNOWN" - table.add_row( - p.name or "Unknown", - p.uuid or "Unknown", - state_str, - p.printer_model or "N/A", - ) - common.console.print(table) + rows.append([p.name or "Unknown", p.uuid or "Unknown", state_str, p.printer_model or "N/A"]) + + common.output_table( + "Printers", + ["Name", "UUID", "State", "Model"], + rows, + column_styles=["cyan", "magenta", "green", "blue"], + ) def printers_alias( @@ -77,11 +76,7 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then\n" - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return common.logger.debug("Command started", command="printer show", printer_id=resolved_id) @@ -90,49 +85,42 @@ def printer_show( try: p = client.printers.get(resolved_id) - # Basic Info Table - table = Table(title=f"Printer: {p.name}") - table.add_column("Field", style="cyan") - table.add_column("Value", style="magenta") + # Build rows and track section boundaries + rows: list[list[str]] = [] + sections: set[int] = set() - table.add_row("UUID", p.uuid or "N/A") - table.add_row("State", p.printer_state or "N/A") - table.add_row("Model", p.printer_model or "N/A") + rows.append(["UUID", p.uuid or "N/A"]) + rows.append(["State", p.printer_state or "N/A"]) + rows.append(["Model", p.printer_model or "N/A"]) # Firmware fw_str = p.firmware_version or "Unknown" if p.support and p.support.latest and p.support.latest != p.firmware_version: - # Check if current != latest - # The 'current' field in support might be more accurate or redundant with p.firmware_version fw_str += f" [yellow](Latest: {p.support.latest})[/yellow]" - table.add_row("Firmware", fw_str) + rows.append(["Firmware", fw_str]) - # Location / Team if p.location: - table.add_row("Location", p.location) + rows.append(["Location", p.location]) if p.team_name: - table.add_row("Team", p.team_name) + rows.append(["Team", p.team_name]) # Network Info if p.network_info: - table.add_section() + sections.add(len(rows)) if p.network_info.hostname: - table.add_row("Hostname", p.network_info.hostname) + rows.append(["Hostname", p.network_info.hostname]) if p.network_info.lan_ipv4: - table.add_row("IP Address", p.network_info.lan_ipv4) + rows.append(["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) + rows.append(["Last Online", last_seen]) - # Tool 1 Material (Default View) + # Material material = "N/A" - # Try to find material from tools or slots if p.tools and "1" in p.tools: material = p.tools["1"].material or "N/A" - - # If no tool material, maybe checking active slot? if (material == "N/A" or material == "---") and p.slot and p.slot.active is not None: active_slot_key = str(p.slot.active) if p.slot.slots and active_slot_key in p.slot.slots: @@ -140,75 +128,82 @@ def printer_show( if m and m != "---": material = f"{m} (Slot {active_slot_key})" - table.add_section() - table.add_row("Material", material) + sections.add(len(rows)) + rows.append(["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") + rows.append(["Nozzle", f"{p.telemetry.temp_nozzle}°C"]) + rows.append(["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}%") + sections.add(len(rows)) + rows.append(["Job", p.job.display_name or "Unknown"]) + rows.append(["Progress", f"{p.job.progress}%"]) if p.job.time_printing: - table.add_row("Time Printing", str(p.job.time_printing)) + rows.append(["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) + rows.append(["Time Remaining", str(p.job.time_remaining)]) + + common.output_table( + f"Printer: {p.name}", + ["Field", "Value"], + rows, + column_styles=["cyan", "magenta"], + sections_before=sections, + ) if detailed: - # Detailed View - MMU Slots + # MMU Slots if p.slot and p.slot.slots: - slot_table = Table(title="MMU Slots") - slot_table.add_column("Slot", style="cyan") - slot_table.add_column("Material", style="magenta") - slot_table.add_column("Temp", style="yellow") - - # Sort by slot ID sorted_slots = sorted(p.slot.slots.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 999) - for slot_id, slot_data in sorted_slots: - slot_table.add_row( - slot_id, slot_data.material or "---", str(slot_data.temp) if slot_data.temp else "N/A" - ) - common.console.print(slot_table) + slot_rows = [ + [slot_id, slot_data.material or "---", str(slot_data.temp) if slot_data.temp else "N/A"] + for slot_id, slot_data in sorted_slots + ] + common.output_table( + "MMU Slots", + ["Slot", "Material", "Temp"], + slot_rows, + column_styles=["cyan", "magenta", "yellow"], + ) - # Tools Detail (Fans etc) + # Tools if p.tools: - tool_table = Table(title="Tools / Heads") - tool_table.add_column("Tool", style="cyan") - tool_table.add_column("Nozzle", style="green") - tool_table.add_column("Material", style="magenta") - tool_table.add_column("Fan Print", style="blue") - tool_table.add_column("Fan Hotend", style="blue") - - for tool_id, tool_data in p.tools.items(): - tool_table.add_row( + tool_rows = [ + [ tool_id, str(tool_data.nozzle_diameter) if tool_data.nozzle_diameter else "N/A", tool_data.material or "---", f"{tool_data.fan_print}%" if tool_data.fan_print is not None else "N/A", f"{tool_data.fan_hotend}%" if tool_data.fan_hotend is not None else "N/A", - ) - common.console.print(tool_table) + ] + for tool_id, tool_data in p.tools.items() + ] + common.output_table( + "Tools / Heads", + ["Tool", "Nozzle", "Material", "Fan Print", "Fan Hotend"], + tool_rows, + column_styles=["cyan", "green", "magenta", "blue", "blue"], + ) - # Axis Info - axis_table = Table(title="Axis Positions") - axis_table.add_column("Axis", style="cyan") - axis_table.add_column("Position", style="yellow") + # Axis + axis_rows = [] if p.axis_x is not None: - axis_table.add_row("X", str(p.axis_x)) + axis_rows.append(["X", str(p.axis_x)]) if p.axis_y is not None: - axis_table.add_row("Y", str(p.axis_y)) + axis_rows.append(["Y", str(p.axis_y)]) if p.axis_z is not None: - axis_table.add_row("Z", str(p.axis_z)) - - if axis_table.row_count > 0: - common.console.print(axis_table) + axis_rows.append(["Z", str(p.axis_z)]) + if axis_rows: + common.output_table( + "Axis Positions", + ["Axis", "Position"], + axis_rows, + column_styles=["cyan", "yellow"], + ) - rprint("\n[bold]Raw Detailed Information:[/bold]") - detail_table = Table(show_header=False, box=None) + # Raw extra fields + detail_rows = [] for k, v in p.model_dump(mode="json").items(): if v is not None and k not in [ "uuid", @@ -227,11 +222,17 @@ def printer_show( "job", ]: val_str = json.dumps(v) if isinstance(v, (dict, list)) else str(v) - detail_table.add_row(f"[cyan]{k}[/cyan]:", val_str) - common.console.print(detail_table) + detail_rows.append([k, val_str]) + if detail_rows: + common.output_table( + "Raw Detailed Information", + ["Field", "Value"], + detail_rows, + column_styles=["cyan", None], + ) except exceptions.PrusaConnectError as e: - rprint(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @printer_app.command(name="pause") @@ -241,7 +242,7 @@ def printer_pause( """Pause print on one or more printers.""" ids = printer_ids or ([config.settings.default_printer_id] if config.settings.default_printer_id else []) if not ids: - rprint("[red]No printer IDs provided and no default configured.[/red]") + common.output_message("No printer IDs provided and no default configured.", error=True) return common.logger.debug("Command started", command="printer pause", printer_ids=ids) @@ -255,7 +256,7 @@ def printer_resume( """Resume print on one or more printers.""" ids = printer_ids or ([config.settings.default_printer_id] if config.settings.default_printer_id else []) if not ids: - rprint("[red]No printer IDs provided and no default configured.[/red]") + common.output_message("No printer IDs provided and no default configured.", error=True) return common.logger.debug("Command started", command="printer resume", printer_ids=ids) @@ -271,7 +272,7 @@ def printer_stop( """Stop print on one or more printers, optionally setting a failure reason.""" ids = printer_ids or ([config.settings.default_printer_id] if config.settings.default_printer_id else []) if not ids: - rprint("[red]No printer IDs provided and no default configured.[/red]") + common.output_message("No printer IDs provided and no default configured.", error=True) return common.logger.debug("Command started", command="printer stop", printer_ids=ids, reason=reason) @@ -279,41 +280,32 @@ def printer_stop( for pid in ids: try: - # 1. Stop the print if client.stop_print(pid): - rprint(f"[green]Sent STOP_PRINT to {pid}[/green]") + common.output_message(f"Sent STOP_PRINT to {pid}") - # 2. Set reason if provided if reason: - # We need the current job ID to set the reason - # Fetch printer status to get job ID try: p = client.printers.get(pid) if p.job and p.job.id: - # Validate reason string against Enum - - # Accept a case insensitive reason string try: enum_reason = models.JobFailureTag(reason.upper()) client.set_job_failure_reason(pid, p.job.id, enum_reason, note) - rprint(f"[green]Set failure reason '{enum_reason}' for Job {p.job.id}[/green]") + common.output_message(f"Set failure reason '{enum_reason}' for Job {p.job.id}") except ValueError: - rprint( - f"[yellow]Invalid reason code '{reason}'. Supported: " - f"{', '.join([r.value for r in models.JobFailureTag])}[/yellow]" + common.output_message( + f"Invalid reason code '{reason}'. Supported: " + f"{', '.join([r.value for r in models.JobFailureTag])}" ) else: - rprint( - "[yellow]Could not determine Job ID to set failure reason " - "(printer has no active job info).[/yellow]" + common.output_message( + "Could not determine Job ID to set failure reason (printer has no active job info)." ) except Exception as e: - rprint(f"[red]Failed to set failure reason: {e}[/red]") - + common.output_message(f"Failed to set failure reason: {e}", error=True) else: - rprint(f"[red]Failed to send STOP_PRINT to {pid}[/red]") + common.output_message(f"Failed to send STOP_PRINT to {pid}", error=True) except Exception as e: - rprint(f"[red]Failed to send STOP_PRINT to {pid}: {e}[/red]") + common.output_message(f"Failed to send STOP_PRINT to {pid}: {e}", error=True) @printer_app.command(name="cancel-object") @@ -324,22 +316,18 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return common.logger.debug("Command started", command="printer cancel-object", printer_id=resolved_id, object_id=object_id) client = common.get_client() try: if client.cancel_object(resolved_id, object_id): - rprint(f"[green]Successfully sent CANCEL_OBJECT for object {object_id} to {resolved_id}[/green]") + common.output_message(f"Successfully sent CANCEL_OBJECT for object {object_id} to {resolved_id}") else: - rprint("[red]Failed to send CANCEL_OBJECT command[/red]") + common.output_message("Failed to send CANCEL_OBJECT command", error=True) except Exception as e: - rprint(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @printer_app.command(name="move") @@ -354,22 +342,21 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return + if not any([x, y, z, e]): + common.output_message("At least one axis (x, y, z, or e) must be specified", error=True) + common.logger.debug("Command started", command="printer move", printer_id=resolved_id) client = common.get_client() try: if client.move_axis(resolved_id, x=x, y=y, z=z, e=e, speed=speed): - rprint(f"[green]Successfully sent MOVE command to {resolved_id}[/green]") + common.output_message(f"Successfully sent MOVE command to {resolved_id}") else: - rprint("[red]Failed to send MOVE command[/red]") + common.output_message("Failed to send MOVE command", error=True) except Exception as e: - rprint(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @printer_app.command(name="flash") @@ -382,22 +369,18 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return common.logger.debug("Command started", command="printer flash", printer_id=resolved_id, file_path=file_path) client = common.get_client() try: if client.flash_firmware(resolved_id, file_path): - rprint(f"[green]Successfully sent FLASH command to {resolved_id}[/green]") + common.output_message(f"Successfully sent FLASH command to {resolved_id}") else: - rprint("[red]Failed to send FLASH command[/red]") + common.output_message("Failed to send FLASH command", error=True) except Exception as e: - rprint(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @printer_app.command(name="commands") @@ -407,11 +390,7 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return common.logger.debug("Command started", command="printer commands", printer_id=resolved_id) @@ -421,14 +400,11 @@ def printer_commands( commands = client.get_supported_commands(resolved_id) # Deduplicate commands - # The API can return "duplicates" that differ only by internal fields like 'template'. - # We group by name and signature (arguments) to avoid showing redundant entries. unique_map: dict[str, list] = {} for cmd in commands: if cmd.command not in unique_map: unique_map[cmd.command] = [] - # Check for signature match is_duplicate = False current_args_json = json.dumps( [a.model_dump(include={"name", "type", "required"}) for a in cmd.args], sort_keys=True @@ -447,14 +423,8 @@ def printer_commands( unique_commands = [cmd for sublist in unique_map.values() for cmd in sublist] - table = Table(title=f"Supported Commands for {printer_id}") - table.add_column("Command", style="cyan") - table.add_column("Description", style="white") - table.add_column("Arguments", style="yellow") - table.add_column("Valid States", style="green") - + rows = [] for cmd in sorted(unique_commands, key=lambda x: x.command): - # Format arguments args_str = "" if cmd.args: arg_list = [] @@ -463,19 +433,24 @@ def printer_commands( arg_list.append(f"{arg.name}{req_mark}") args_str = ", ".join(arg_list) - # Format states states_str = ", ".join(cmd.executable_from_state) if cmd.executable_from_state else "ALL" if len(states_str) > 30: states_str = states_str[:27] + "..." - table.add_row(cmd.command, cmd.description or "", args_str, states_str) + rows.append([cmd.command, cmd.description or "", args_str, states_str]) + + common.output_table( + f"Supported Commands for {resolved_id}", + ["Command", "Description", "Arguments", "Valid States"], + rows, + column_styles=["cyan", "white", "yellow", "green"], + ) - common.console.print(table) if not commands: - rprint("[yellow]No supported commands found (or printer does not support command discovery).[/yellow]") + common.output_message("No supported commands found (or printer does not support command discovery).") except Exception as e: - rprint(f"[red]Error fetching commands:[/red] {e}") + common.output_message(f"Error fetching commands: {e}", error=True) @printer_app.command(name="command") @@ -506,11 +481,7 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return common.logger.debug( @@ -527,7 +498,6 @@ def printer_execute_command( # 1. Parse Arguments final_args = {} - # Load from JSON if provided if args: try: json_parsed = json.loads(args) @@ -535,80 +505,58 @@ def printer_execute_command( raise ValueError("--args must be a JSON object") final_args.update(json_parsed) except json.JSONDecodeError as e: - rprint(f"[red]Invalid JSON in --args:[/red] {e}") + common.output_message(f"Invalid JSON in --args: {e}", error=True) return - # Load from kwargs (flags) - # cyclopts passes these as strings - # client.execute_printer_command does simple type checking: - # if arg_def.type == "integer" and not isinstance(val, int): raise... - # So we MUST cast here. - - # To cast correctly, we need the command definition! - - # Fetch definition first to know types supported = client.get_supported_commands(resolved_id) cmd_def = next((c for c in supported if c.command == command_name), None) if not cmd_def: - rprint(f"[red]Command '{command_name}' not supported by printer {resolved_id}.[/red]") - # Suggest close matches? + common.output_message(f"Command '{command_name}' not supported by printer {resolved_id}.", error=True) matches = fnmatch.filter([c.command for c in supported], f"*{command_name}*") if matches: - rprint(f"Did you mean: {', '.join(matches)}?") + common.output_message(f"Did you mean: {', '.join(matches)}?") return - # Merge kwargs into final_args, converting types for k, v in kwargs.items(): - # Match k to arg name (command args are snake_case usually) - # CLI flags are kebab-case but cyclopts normalizes them to snake_case - - # Find the argument definition arg_def = next((a for a in cmd_def.args if a.name == k), None) if arg_def: - # Cast based on type if arg_def.type == "integer": try: final_args[k] = int(v) except ValueError: - rprint(f"[red]Argument '{k}' must be an integer (got '{v}')[/red]") + common.output_message(f"Argument '{k}' must be an integer (got '{v}')", error=True) return elif arg_def.type == "number": try: final_args[k] = float(v) except ValueError: - rprint(f"[red]Argument '{k}' must be a number (got '{v}')[/red]") + common.output_message(f"Argument '{k}' must be a number (got '{v}')", error=True) return elif arg_def.type == "boolean": - # CLI flags for bools: usually presence means True - # cyclopts **kwargs treats flags with values if str(v).lower() in ("true", "1", "yes", "on"): final_args[k] = True elif str(v).lower() in ("false", "0", "no", "off"): final_args[k] = False else: - rprint(f"[red]Argument '{k}' must be a boolean (got '{v}')[/red]") + common.output_message(f"Argument '{k}' must be a boolean (got '{v}')", error=True) return else: final_args[k] = v else: - # Unknown argument - # Client doesn't strict check unknown extra args in 'execute_printer_command', - # it blindly passes everything to send_command after verifying *required*. - # But it DOES check known args for types. final_args[k] = v # 2. Execute success = client.execute_printer_command(resolved_id, command_name, final_args) if success: - rprint(f"[green]Successfully sent command '{command_name}' to {resolved_id}[/green]") + common.output_message(f"Successfully sent command '{command_name}' to {resolved_id}") else: - rprint(f"[red]Failed to send command '{command_name}'[/red]") + common.output_message(f"Failed to send command '{command_name}'", error=True) except ValueError as e: - rprint(f"[red]Validation Error:[/red] {e}") + common.output_message(f"Validation Error: {e}", error=True) except Exception as e: - rprint(f"[red]Error executing command:[/red] {e}") + common.output_message(f"Error executing command: {e}", error=True) @printer_app.command(name="storages") @@ -618,35 +566,32 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return client = common.get_client() try: storages = client.get_printer_storages(resolved_id) - table = Table(title=f"Storages for {resolved_id}") - table.add_column("Name", style="cyan") - table.add_column("Type", style="green") - table.add_column("Mountpoint", style="magenta") - table.add_column("Free", style="yellow") - table.add_column("ReadOnly", style="red") - + rows = [] for s in storages: free_str = f"{s.free_space / 1024 / 1024 / 1024:.2f} GB" if s.free_space else "N/A" - table.add_row( - s.name, - s.type, - s.mountpoint or s.path, - free_str, - "Yes" if s.read_only else "No", + rows.append( + [ + s.name, + s.type, + s.mountpoint or s.path, + free_str, + "Yes" if s.read_only else "No", + ] ) - common.console.print(table) + common.output_table( + f"Storages for {resolved_id}", + ["Name", "Type", "Mountpoint", "Free", "ReadOnly"], + rows, + column_styles=["cyan", "green", "magenta", "yellow", "red"], + ) except Exception as e: - rprint(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @files_printer_app.command(name="list") @@ -656,32 +601,27 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return client = common.get_client() try: files = client.get_printer_files(resolved_id) - table = Table(title=f"Files on {resolved_id}") - table.add_column("Name", style="cyan") - table.add_column("Path", style="magenta") - table.add_column("Size", style="green") - table.add_column("Modified", style="yellow") - + rows = [] for f in files: size_str = f"{f.size / 1024 / 1024:.2f} MB" if f.size else "N/A" mtime_str = "N/A" if f.m_timestamp: mtime_str = datetime.datetime.fromtimestamp(f.m_timestamp).strftime("%Y-%m-%d %H:%M:%S") - - table.add_row(f.name, f.path, size_str, mtime_str) - common.console.print(table) + rows.append([f.name, f.path or "", size_str, mtime_str]) + common.output_table( + f"Files on {resolved_id}", + ["Name", "Path", "Size", "Modified"], + rows, + column_styles=["cyan", "magenta", "green", "yellow"], + ) except Exception as e: - rprint(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @files_printer_app.command(name="upload") @@ -693,25 +633,20 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return client = common.get_client() try: 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: target_team = teams[0] - rprint(f"[yellow]Could not resolve team ID for printer. Using first team: {target_team.name}[/yellow]") + common.output_message(f"Could not resolve team ID for printer. Using first team: {target_team.name}") if not target_team: - rprint("[red]Could not determine team for upload.[/red]") + common.output_message("Could not determine team for upload.", error=True) return from prusa.connect.client.cli.commands import file @@ -719,7 +654,7 @@ def printer_files_upload( file.file_upload(path=path, team_id=target_team.id, destination=destination) except Exception as e: - rprint(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @files_printer_app.command(name="download") @@ -731,11 +666,7 @@ 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]\n" - "[dim]Hint: Run 'prusactl printer list' to find a UUID, then " - "'prusactl printer set-current ' to set the default.[/dim]" - ) + common.output_message(_NO_PRINTER, error=True) return client = common.get_client() @@ -747,7 +678,7 @@ def printer_files_download( target_team = teams[0] if not target_team: - rprint("[red]Could not determine team for download.[/red]") + common.output_message("Could not determine team for download.", error=True) return from prusa.connect.client.cli.commands import file @@ -755,7 +686,7 @@ def printer_files_download( file.file_download(file_hash=file_hash, team_id=target_team.id, output=output) except Exception as e: - rprint(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @printer_app.command(name="set-current") @@ -763,4 +694,4 @@ def set_current_printer(printer_id: typing.Annotated[str, cyclopts.Parameter(hel """Set the default printer UUID for future commands.""" config.settings.default_printer_id = printer_id config.save_json_config(config.settings) - rprint(f"[green]Successfully set default printer to {printer_id}[/green]") + common.output_message(f"Successfully set default printer to {printer_id}") diff --git a/src/prusa/connect/client/cli/commands/stats.py b/src/prusa/connect/client/cli/commands/stats.py index 28bbe4e..4a5c429 100644 --- a/src/prusa/connect/client/cli/commands/stats.py +++ b/src/prusa/connect/client/cli/commands/stats.py @@ -4,13 +4,18 @@ 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 +_NO_PRINTER = ( + "No printer ID provided and no default configured.\n" + "Hint: Run 'prusactl printer list' to find a UUID, then " + "'prusactl printer set-current ' to set the default." +) + @stats_app.command(name="usage") def stats_usage( @@ -20,15 +25,12 @@ def stats_usage( 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, + seconds: typing.Annotated[bool, cyclopts.Parameter(help="Output duration in seconds")] = False, ): """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]" - ) + common.output_message(_NO_PRINTER, error=True) return client = common.get_client() @@ -39,16 +41,18 @@ def stats_usage( 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) + rows = [ + [entry.name, str(entry.duration) if not seconds else str(entry.duration.total_seconds())] + for entry in stats.data + ] + common.output_table( + f"Usage Stats for {stats.printer_name} ({from_date} to {to_date})", + ["Type", "Duration"], + rows, + column_styles=["cyan", "magenta"], + ) except Exception as e: - common.console.print(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @stats_app.command(name="material") @@ -63,11 +67,7 @@ def stats_material( """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]" - ) + common.output_message(_NO_PRINTER, error=True) return client = common.get_client() @@ -79,22 +79,24 @@ def stats_material( 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") - + rows = [] if not stats.data: - table.add_row("No data available", "") + rows.append(["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"))) + rows.append([entry.get("name", "Unknown"), str(entry.get("value", "N/A"))]) else: - table.add_row("Raw Data", str(entry)) + rows.append(["Raw Data", str(entry)]) - common.console.print(table) + common.output_table( + f"Material Stats for {stats.printer_name} ({from_date} to {to_date})", + ["Material", "Usage"], + rows, + column_styles=["cyan", "magenta"], + ) except Exception as e: - common.console.print(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @stats_app.command(name="jobs") @@ -109,11 +111,7 @@ def stats_jobs( """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]" - ) + common.output_message(_NO_PRINTER, error=True) return client = common.get_client() @@ -129,20 +127,20 @@ def stats_jobs( 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") - + columns = ["Status", *list(stats.date_axis)] + rows = [] 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) + row = [series.status.name] + [str(v) for v in series.data] + rows.append(row) + + common.output_table( + f"Job Success Stats for {stats.printer_name} ({from_date} to {to_date})", + columns, + rows, + column_styles=["cyan"] + ["magenta"] * len(stats.date_axis), + ) except Exception as e: - common.console.print(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) @stats_app.command(name="planned") @@ -157,11 +155,7 @@ def stats_planned( """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]" - ) + common.output_message(_NO_PRINTER, error=True) return client = common.get_client() @@ -172,16 +166,19 @@ def stats_planned( 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") + rows = [] if stats.series and stats.series.data: for hour, count in stats.series.data: - table.add_row(f"{hour:02d}:00", str(count)) + rows.append([f"{hour:02d}:00", str(count)]) else: - table.add_row("No data available", "") + rows.append(["No data available", ""]) - common.console.print(table) + common.output_table( + f"Planned Tasks for {stats.series.printer_name} ({from_date} to {to_date})", + ["Hour (UTC)", "Count"], + rows, + column_styles=["cyan", "magenta"], + ) except Exception as e: - common.console.print(f"[red]Error:[/red] {e}") + common.output_message(f"Error: {e}", error=True) diff --git a/src/prusa/connect/client/cli/commands/team.py b/src/prusa/connect/client/cli/commands/team.py index 7426405..bdbf8a0 100644 --- a/src/prusa/connect/client/cli/commands/team.py +++ b/src/prusa/connect/client/cli/commands/team.py @@ -1,11 +1,10 @@ """Team commands.""" +import json import sys import typing import cyclopts -from rich import print as rprint -from rich.table import Table from prusa.connect.client.cli import common, config from prusa.connect.client.cli.commands.job import job_list @@ -19,20 +18,13 @@ def list_teams(): client = common.get_client() teams = client.teams.list_teams() - table = Table(title="Teams") - table.add_column("ID", style="cyan") - table.add_column("Name", style="green") - table.add_column("Role", style="magenta") - table.add_column("Organization ID", style="blue") - - for team in teams: - table.add_row( - str(team.id), - team.name, - str(team.role or "N/A"), - str(team.organization_id or "N/A"), - ) - rprint(table) + rows = [[str(team.id), team.name, str(team.role or "N/A"), str(team.organization_id or "N/A")] for team in teams] + common.output_table( + "Teams", + ["ID", "Name", "Role", "Organization ID"], + rows, + column_styles=["cyan", "green", "magenta", "blue"], + ) @team_app.command(name="show") @@ -45,41 +37,39 @@ def show_team( """Show details for a specific team.""" team_id_to_use = team_id 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]") + common.output_message("Error: Team ID not provided and no default is set.", error=True) sys.exit(1) client = common.get_client() try: team = client.teams.get(team_id_to_use) except Exception as e: - rprint(f"[red]Error fetching team {team_id_to_use}: {e}[/red]") + common.output_message(f"Error fetching team {team_id_to_use}: {e}", error=True) sys.exit(1) - table = Table(title=f"Team Details: {team.name}") - table.add_column("Property", style="cyan") - table.add_column("Value", style="green") - - table.add_row("ID", str(team.id)) - table.add_row("Name", team.name) - table.add_row("Role", str(team.role or "N/A")) + rows = [ + ["ID", str(team.id)], + ["Name", team.name], + ["Role", str(team.role or "N/A")], + ] if team.description: - table.add_row("Description", team.description) + rows.append(["Description", team.description]) if team.capacity is not None: - table.add_row("Capacity", str(team.capacity)) + rows.append(["Capacity", str(team.capacity)]) if team.organization_id: - table.add_row("Organization ID", str(team.organization_id)) + rows.append(["Organization ID", str(team.organization_id)]) if team.user_count is not None: - table.add_row("User Count", str(team.user_count)) + rows.append(["User Count", str(team.user_count)]) - rprint(table) + common.output_table( + f"Team Details: {team.name}", + ["Property", "Value"], + rows, + column_styles=["cyan", "green"], + ) if getattr(team, "users", None): - users_table = Table(title="Team Users") - users_table.add_column("ID", style="cyan") - users_table.add_column("Name", style="green") - users_table.add_column("Username", style="magenta") - users_table.add_column("Rights", style="yellow") - + user_rows = [] for u in team.users: name_parts = [p for p in [u.first_name, u.last_name] if p] name = " ".join(name_parts) if name_parts else "N/A" @@ -90,15 +80,17 @@ def show_team( rights.append("RW") if u.rights_use: rights.append("USE") + user_rows.append([str(u.id), name, u.public_name or "N/A", ", ".join(rights) if rights else "NONE"]) - users_table.add_row(str(u.id), name, u.public_name or "N/A", ", ".join(rights) if rights else "NONE") - rprint(users_table) + common.output_table( + "Team Users", + ["ID", "Name", "Username", "Rights"], + user_rows, + column_styles=["cyan", "green", "magenta", "yellow"], + ) if detailed: - import json - - rprint("\n[bold]Detailed Information:[/bold]") - detail_table = Table(show_header=False, box=None) + detail_rows = [] for k, v in team.model_dump(mode="json").items(): if v is not None and k not in [ "id", @@ -111,8 +103,15 @@ def show_team( "users", ]: val_str = json.dumps(v) if isinstance(v, (dict, list)) else str(v) - detail_table.add_row(f"[cyan]{k}[/cyan]:", val_str) - common.console.print(detail_table) + detail_rows.append([k, val_str]) + + if detail_rows: + common.output_table( + "Detailed Information", + ["Field", "Value"], + detail_rows, + column_styles=["cyan", None], + ) @team_app.command(name="add-user") @@ -126,27 +125,25 @@ def add_team_user( """Invite a user to a team.""" team_id_to_use = team_id 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]") + common.output_message("Error: Team ID not provided and no default is set.", error=True) sys.exit(1) client = common.get_client() try: if client.add_team_user(team_id_to_use, email, rights_ro, rights_use, rights_rw): - rprint(f"[green]Successfully sent invitation to {email}[/green]") + common.output_message(f"Successfully sent invitation to {email}") except Exception as e: from prusa.connect.client import exceptions if isinstance(e, exceptions.PrusaApiError): - import json - try: err_data = json.loads(e.response_body) msg = err_data.get("message", e.response_body) - rprint(f"[red]Failed to add user:[/red] {msg}") + common.output_message(f"Failed to add user: {msg}", error=True) except json.JSONDecodeError: - rprint(f"[red]Failed to add user:[/red] {e.response_body}") + common.output_message(f"Failed to add user: {e.response_body}", error=True) else: - rprint(f"[red]Failed to add user: {e}[/red]") + common.output_message(f"Failed to add user: {e}", error=True) @team_app.command(name="set-current") @@ -156,7 +153,7 @@ def set_current_team( """Set the default team ID for future commands.""" config.settings.default_team_id = team_id config.save_json_config(config.settings) - rprint(f"[green]Successfully set default team to {team_id}[/green]") + common.output_message(f"Successfully set default team to {team_id}") def teams_alias(): @@ -174,7 +171,7 @@ def team_jobs_alias( """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]") + common.output_message("Error: Team ID not provided and no default is set.", error=True) sys.exit(1) job_list(team=team_id_to_use, printer=printer, state=state, limit=limit) diff --git a/src/prusa/connect/client/cli/common.py b/src/prusa/connect/client/cli/common.py index 39685f1..3fd9577 100644 --- a/src/prusa/connect/client/cli/common.py +++ b/src/prusa/connect/client/cli/common.py @@ -1,5 +1,7 @@ """Shared CLI helpers and configuration.""" +import collections.abc +import json as _json import logging import pathlib import sys @@ -9,7 +11,7 @@ import platformdirs import structlog from rich import console as rich_console -from rich import print as rprint +from rich.text import Text from prusa.connect.client import auth, exceptions, sdk from prusa.connect.client import consts as sdk_consts @@ -21,8 +23,110 @@ # Setup better_exceptions.hook() console = rich_console.Console() +err_console = rich_console.Console(stderr=True) logger = structlog.get_logger(sdk_consts.APP_NAME) +# -- Output format ---------------------------------------------------------- + +_output_format: config.OutputFormat | None = None # None means "resolve lazily from config/TTY" + + +def set_output_format(fmt: str | None) -> None: + """Set the output format (called from --format CLI flag). + + Calls `sys.exit` if an invalid format is specified. + """ + global _output_format + try: + _output_format = config.OutputFormat(fmt) if fmt is not None else None + except ValueError: + output_message( + ( + f"[bold][red]Error[/red][/bold]: `{fmt}` is not a valid output " + f"format. Valid formats are: {', '.join(config.OutputFormat.__members__.values())}" + ), + error=True, + ) + sys.exit(1) + + +def get_output_format() -> config.OutputFormat: + """Resolve the active output format: CLI flag > config > TTY auto-detect.""" + if _output_format is not None: + return _output_format + cfg_fmt = getattr(config.settings, "output_format", None) + if cfg_fmt: + return cfg_fmt + return config.OutputFormat.RICH if sys.stdout.isatty() else config.OutputFormat.PLAIN + + +def _strip_markup(text: str) -> str: + """Remove Rich markup tags from a string.""" + return Text.from_markup(str(text)).plain + + +def output_message(msg: str, *, error: bool = False) -> None: + """Print a status/error message respecting the current output format. + + - rich: renders markup with color to stdout (or stderr for errors) + - plain: strips markup, writes to stdout (errors to stderr) + - json: strips markup, always writes to stderr (stdout reserved for JSON) + """ + fmt = get_output_format() + if fmt in ("plain", "json"): + plain = _strip_markup(msg) + to_stderr = error or fmt == "json" + print(plain, file=sys.stderr if to_stderr else sys.stdout) + else: + target = err_console if error else console + target.print(msg) + + +def output_table( + title: str, + columns: list[str], + rows: list[list[str]], + *, + column_styles: collections.abc.Sequence[str | None] | None = None, + sections_before: set[int] | None = None, +) -> None: + """Print tabular data respecting the current output format. + + Args: + title: Table title (used as rich title; as ``# title`` comment in plain). + columns: Column header names. + rows: Row data as lists of strings (may contain Rich markup; stripped in + plain/json modes). + column_styles: Optional per-column Rich style names (ignored in plain/json). + sections_before: Set of row indices before which ``table.add_section()`` + is called (rich only; ignored in plain/json). + """ + from rich.table import Table as _RichTable + + fmt = get_output_format() + + if fmt == "json": + keys = [c.lower().replace(" ", "_").replace("(", "").replace(")", "").strip("_") for c in columns] + data = [dict(zip(keys, [_strip_markup(c) for c in row], strict=False)) for row in rows] + print(_json.dumps(data)) + elif fmt == "plain": + print(f"# {title}") + print("\t".join(columns)) + for row in rows: + print("\t".join(_strip_markup(c) for c in row)) + else: + table = _RichTable(title=title) + styles = column_styles or [] + for i, col in enumerate(columns): + style = styles[i] if i < len(styles) else None + table.add_column(col, style=style) + for i, row in enumerate(rows): + if sections_before and i in sections_before: + table.add_section() + table.add_row(*[str(c) for c in row]) + console.print(table) + + _LOGGING_INITIALIZED = False @@ -102,8 +206,8 @@ def get_client(require_auth: bool = True) -> sdk.PrusaConnectClient: creds = None if (creds is None or not creds.valid) and require_auth: - rprint("[red]Authentication required.[/red]") - rprint("Please run [bold]prusactl auth login[/bold] to authenticate.") + output_message("Authentication required.", error=True) + output_message("Please run 'prusactl auth login' to authenticate.", error=True) sys.exit(1) cache_dir = pathlib.Path(platformdirs.user_cache_dir(sdk_consts.APP_NAME, sdk_consts.APP_AUTHOR)) diff --git a/src/prusa/connect/client/cli/config.py b/src/prusa/connect/client/cli/config.py index 95f51c0..b9f1eb7 100644 --- a/src/prusa/connect/client/cli/config.py +++ b/src/prusa/connect/client/cli/config.py @@ -1,5 +1,6 @@ """Configuration handling for the CLI.""" +import enum import json import pathlib import typing @@ -39,6 +40,14 @@ def load_json_config() -> dict[str, typing.Any]: return {} +class OutputFormat(enum.StrEnum): + """Enum of available console out formats.""" + + RICH = "rich" + PLAIN = "plain" + JSON = "json" + + class Settings(pydantic_settings.BaseSettings): """Application-wide settings loaded from config.json, .env or environment variables.""" @@ -52,6 +61,14 @@ class Settings(pydantic_settings.BaseSettings): tokens_file: pathlib.Path = pydantic.Field(default_factory=auth.get_default_token_path) cache_ttl_hours: int = consts.DEFAULT_CACHE_TTL_HOURS + # Output format: "rich" (colored tables), "plain" (tab-separated, no markup), + # or "json" (JSON arrays to stdout). None means auto-detect from TTY. + # Override via env var PRUSACTL_OUTPUT_FORMAT or config.json key "output_format". + output_format: OutputFormat | None = pydantic.Field( + default=None, + validation_alias=pydantic.AliasChoices("PRUSACTL_OUTPUT_FORMAT", "output_format"), + ) + model_config = pydantic_settings.SettingsConfigDict(env_file=".env", extra="ignore") @classmethod diff --git a/src/prusa/connect/client/cli/main.py b/src/prusa/connect/client/cli/main.py index 7bf1875..41ce7ea 100644 --- a/src/prusa/connect/client/cli/main.py +++ b/src/prusa/connect/client/cli/main.py @@ -29,7 +29,7 @@ # Register Aliases and Commands app.command(printer.printers_alias, name="printers") -app.command(camera.camera_alias, name="cameras") +app.command(camera.cameras_alias, name="cameras") app.command(job.jobs_alias, name="jobs") app.command(file.files_alias, name="files") app.command(team.teams_alias, name="teams") @@ -44,11 +44,21 @@ def entry_point( bool, cyclopts.Parameter(name=["--verbose", "-v"], help="Enable verbose logging") ] = False, debug: typing.Annotated[bool, cyclopts.Parameter(name=["--debug"], help="Enable debug logging")] = False, + output_format: typing.Annotated[ + str | None, + cyclopts.Parameter( + name=["--format"], + help="Output format: rich (default on TTY), plain (tab-separated), json", + ), + ] = None, ): """Main entry point handling global flags.""" # Configure logging common.configure_logging(verbose, debug) + if output_format is not None: + common.set_output_format(output_format) + if tokens is None: tokens = [] # Let cyclopts handle the full command parsing (subcommands, help, etc) diff --git a/src/prusa/connect/client/models/stats.py b/src/prusa/connect/client/models/stats.py index 6d107ef..c53e05c 100644 --- a/src/prusa/connect/client/models/stats.py +++ b/src/prusa/connect/client/models/stats.py @@ -61,7 +61,7 @@ class PrintingNotPrintingEntry(WarnExtraFieldsModel): """Represents a single entry in printing vs not printing stats.""" name: str - value: int + duration: datetime.timedelta = pydantic.Field(..., alias="value") class PrintingNotPrinting(StatsModel): diff --git a/tests/unit_tests/test_cli_output.py b/tests/unit_tests/test_cli_output.py new file mode 100644 index 0000000..2ebcf1e --- /dev/null +++ b/tests/unit_tests/test_cli_output.py @@ -0,0 +1,109 @@ +import contextlib +import json +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from prusa.connect.client.cli import common, config + + +@pytest.fixture(autouse=True) +def reset_output_format(): + """Reset the global output format before and after each test.""" + common._output_format = None + yield + common._output_format = None + + +def test_set_output_format_valid(): + common.set_output_format("json") + assert common.get_output_format() == config.OutputFormat.JSON + + common.set_output_format("plain") + assert common.get_output_format() == config.OutputFormat.PLAIN + + common.set_output_format("rich") + assert common.get_output_format() == config.OutputFormat.RICH + + +def test_set_output_format_invalid(): + with pytest.raises(SystemExit) as excinfo: + common.set_output_format("invalid") + assert excinfo.value.code == 1 + + +def test_get_output_format_default_tty(monkeypatch): + # Mock sys.stdout.isatty to return True + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + assert common.get_output_format() == config.OutputFormat.RICH + + +def test_get_output_format_default_no_tty(monkeypatch): + # Mock sys.stdout.isatty to return False + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + assert common.get_output_format() == config.OutputFormat.PLAIN + + +def test_get_output_format_from_config(): + with patch("prusa.connect.client.cli.config.settings") as mock_settings: + mock_settings.output_format = config.OutputFormat.JSON + assert common.get_output_format() == config.OutputFormat.JSON + + +def test_output_message_rich(capsys): + common.set_output_format("rich") + # We can't easily test rich's actual colored output here because it depends on terminal + # but we can check if it calls the console. + with patch("prusa.connect.client.cli.common.console") as mock_console: + common.output_message("Hello [bold]World[/bold]") + mock_console.print.assert_called_once_with("Hello [bold]World[/bold]") + + +def test_output_message_plain(capsys): + common.set_output_format("plain") + common.output_message("Hello [bold]World[/bold]") + captured = capsys.readouterr() + assert captured.out == "Hello World\n" + + +def test_output_message_json(capsys): + common.set_output_format("json") + # JSON format should send messages to stderr + common.output_message("Hello [bold]World[/bold]") + captured = capsys.readouterr() + assert "Hello World" in captured.err + + +def test_output_table_plain(capsys): + common.set_output_format("plain") + common.output_table("My Table", ["Col1", "Col2"], [["R1C1", "R1C2"], ["R2C1", "R2C2"]]) + captured = capsys.readouterr() + expected = "# My Table\nCol1\tCol2\nR1C1\tR1C2\nR2C1\tR2C2\n" + assert captured.out == expected + + +def test_output_table_json(capsys): + common.set_output_format("json") + common.output_table("My Table", ["Col 1", "Col 2"], [["R1C1", "R1C2"]]) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == [{"col_1": "R1C1", "col_2": "R1C2"}] + + +def test_cli_format_flag(): + from prusa.connect.client.cli.main import app + + # Use a command that output something, like 'printer list' + # We need to mock the client to avoid network calls + with patch("prusa.connect.client.cli.commands.printer.common.get_client") as mock_get_client: + client = MagicMock() + client.printers.list_printers.return_value = [] + mock_get_client.return_value = client + + # Test with --format json + with patch("prusa.connect.client.cli.common.set_output_format") as mock_set, contextlib.suppress(SystemExit): + # Use app.meta to handle global flags + app.meta(["--format", "json", "printer", "list"]) + + mock_set.assert_called_with("json") diff --git a/tests/unit_tests/test_stats.py b/tests/unit_tests/test_stats.py index 3427c83..bc6ea20 100644 --- a/tests/unit_tests/test_stats.py +++ b/tests/unit_tests/test_stats.py @@ -39,7 +39,7 @@ def test_get_printer_usage_stats(client): assert stats.printer_name == "printer-name" assert len(stats.data) == 2 assert stats.data[0].name == "printing" - assert stats.data[0].value == 10 + assert stats.data[0].duration.total_seconds() == 10.0 assert isinstance(stats.from_time, datetime.date) From c698ff81f137c35bf4d12eeaab11b1e04e6d4b5d Mon Sep 17 00:00:00 2001 From: Derek Ditch Date: Tue, 24 Feb 2026 04:51:44 +0000 Subject: [PATCH 6/6] chore: Restore documentation updates lost in merge --- README.md | 6 +++--- docs/{contributing.md => CONTRIBUTING.md} | 0 docs/LICENSE | 1 + mkdocs.yml | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) rename docs/{contributing.md => CONTRIBUTING.md} (100%) create mode 120000 docs/LICENSE diff --git a/README.md b/README.md index 5c82d91..15b6eb6 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ provides a frictionless, strongly-typed interface for the Prusa Connect API. > > This SDK is not an officially supported or endorsed product of Prusa Research. > It is developed and maintained by an independent developer and is not -> affiliated with Prusa Research. See [Motivation & Design](#motivation--design) -> for more information. +> affiliated with Prusa Research. See +> [Motivation & Design](#motivation-and-design) for more information. **Features:** @@ -60,7 +60,7 @@ reference is available at: **** -## Motivation & Design +## Motivation and Design My motivation to create this library is to provide a frictionless, strongly-typed interface for the Prusa Connect API. I want to be able to monitor diff --git a/docs/contributing.md b/docs/CONTRIBUTING.md similarity index 100% rename from docs/contributing.md rename to docs/CONTRIBUTING.md diff --git a/docs/LICENSE b/docs/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/docs/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 59b412b..1dbc9bd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,7 +89,7 @@ nav: - Client: api/client.md - Models: api/models.md - Development Resources: - - Contributing: contributing.md + - Contributing: CONTRIBUTING.md - Architecture Notes: internal_architecture.md not_in_nav: | /api/index.md