diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43de84f..e2952ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python: ["3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} permissions: contents: read @@ -70,7 +70,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python: ["3.11", "3.12", "3.13", "3.14"] runs-on: windows-latest permissions: contents: read @@ -118,7 +118,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python: ["3.10", "3.14"] + python: ["3.11", "3.14"] runs-on: ${{ matrix.os }} permissions: contents: read diff --git a/.serena/memories/core.md b/.serena/memories/core.md index cd33aa7..7f60412 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -1,6 +1,6 @@ # qsv-client — core -Python library that runs the qsv CSV toolkit binary as a subprocess (qsv not bundled). Zero runtime deps. Sync `Qsv` + asyncio `AsyncQsv` with identical API. +Python library that runs the qsv CSV toolkit binary as a subprocess (qsv not bundled). Zero runtime deps (daemon.toml is read with stdlib `tomllib`). Sync `Qsv` + asyncio `AsyncQsv` with identical API. Related: stack/versions `mem:tech_stack`; dev/test/run commands `mem:suggested_commands`; code style & sync/async mirroring rules `mem:conventions`; done-checklist (ruff/mypy/pytest as CI runs them) `mem:task_completion`. diff --git a/.serena/memories/tech_stack.md b/.serena/memories/tech_stack.md index 9d1836e..de94c55 100644 --- a/.serena/memories/tech_stack.md +++ b/.serena/memories/tech_stack.md @@ -1,7 +1,7 @@ # Tech stack -- Python >=3.10 (ruff `target-version = py310`; CI matrix 3.10–3.14 on ubuntu + macos). Use `from __future__ import annotations`; no 3.11+ only stdlib features. -- No runtime dependencies — keep it that way (stdlib `subprocess`/`asyncio` only). +- Python >=3.11 (ruff `target-version = py311`; CI matrix 3.11–3.14 on ubuntu + macos). Use `from __future__ import annotations`; no 3.12+ only stdlib features. +- No runtime dependencies — keep it that way (stdlib `subprocess`/`asyncio` only). daemon.toml is read with stdlib `tomllib`. - Package/env manager: uv (`uv.lock` committed); dev deps in `[dependency-groups].dev`: pytest, pytest-asyncio, mypy, ruff. - Build: hatchling, src layout (`src/qsv_client`). License AGPL-3.0-or-later. - pytest: `asyncio_mode = "auto"` (async tests need no marker). diff --git a/CHANGELOG.md b/CHANGELOG.md index 6168df6..81c5f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,23 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.6.0] - 2026-10-26 + +This release accompanies qsv 27.0.0, the first qsv whose Data Schematics connect to +`qsv-clientd`. `qsv-clientd viz ` turns a download link or a CKAN page into a live +schematic in one step, and `qsv-clientd` remembers where the data came from, so Fetch latest +gets the current version. + ### Added - **`qsv-clientd viz `** ([#34](https://github.com/dathere/qsv-client/pull/34)) downloads the data first, then renders, registers and opens the schematic in one step. The URL can be an http(s) download link, `ckan://`, or a CKAN resource page copied from the browser. It is also where Fetch latest gets new data. The data is saved beside the schematic (`--save-as` to choose), readable only by you - since it may be private, never replacing an existing file. `-o` now defaults to - the data's name with `.html`. `--no-open` skips the browser. + since it may be private, never replacing an existing file. A qsv with `get` also keeps a + copy in its own cache (`~/.qsv-cache` by default, or `QSV_CACHE_DIR`), which follows + your umask. `-o` now defaults to the data's name with `.html`. `--no-open` skips the + browser. - **CKAN pages as sources.** ([#34](https://github.com/dathere/qsv-client/pull/34)) A CKAN resource page (`/dataset//resource/`) is fetched as `ckan://` from that site. A CKAN dataset page with exactly one CSV or TSV resource, found with `package_show`, is fetched as that resource; with several, the @@ -23,6 +32,23 @@ All notable changes to this project are documented here. The format follows URL) for `ckan://` sources. - `register --schematic` takes the source URL (and its CKAN site) from a schematic that records one, as qsv records it for data downloaded with `qsv get`. ([#34](https://github.com/dathere/qsv-client/pull/34)) +- With a qsv older than 27.0.0, whose schematics can't connect, `qsv-clientd serve` logs a + warning and the tray shows a notification once, instead of leaving schematics silently + offline. Everything else still works with qsv 25.0.0 or later. + +### Changed + +- **Python 3.11 or later is required**; 3.10 is no longer supported. The `[tray]` extra now + needs Pillow 13.0.0, which fixes its known vulnerabilities and requires Python 3.11. + (The tray opens only its own bundled icon, so they weren't reachable through it.) +- **The daemon's config is now `daemon.toml`**, not `daemon.json`, so it can carry comments. + The tray's Open config… creates it with every setting commented out, each with its + default and the values it takes; nothing changes until you uncomment a line. A + `daemon.json` from an earlier release is no longer read: move its settings across + (`serve --config` with an old JSON file says so). It is read with the standard + library's `tomllib`, so qsv-client still has no runtime dependencies. +- The docs now name the release that brings live Data Schematics: qsv 27.0.0 or later + (before, "a qsv newer than 26.0.0"). Other uses still need only qsv 25.0.0. ### Fixed @@ -278,7 +304,8 @@ First release. ([#1](https://github.com/dathere/qsv-client/pull/1)) environment variables, so keys never appear on the command line. - Type hints (`py.typed`), no runtime dependencies, Python 3.10+. -[Unreleased]: https://github.com/dathere/qsv-client/compare/0.5.0...HEAD +[Unreleased]: https://github.com/dathere/qsv-client/compare/0.6.0...HEAD +[0.6.0]: https://github.com/dathere/qsv-client/compare/0.5.0...0.6.0 [0.5.0]: https://github.com/dathere/qsv-client/compare/0.4.0...0.5.0 [0.4.0]: https://github.com/dathere/qsv-client/compare/0.3.0...0.4.0 [0.3.0]: https://github.com/dathere/qsv-client/compare/0.2.0...0.3.0 diff --git a/CLAUDE.md b/CLAUDE.md index cfda716..3b98140 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Python library that runs the [qsv](https://github.com/dathere/qsv) CSV toolkit as a subprocess (qsv itself is not bundled). Sync `Qsv` and asyncio `AsyncQsv` clients with the same API. -No runtime dependencies. Python 3.10+. +No runtime dependencies (the daemon's config is read with the stdlib's `tomllib`). Python 3.11+. ## Commands @@ -14,9 +14,9 @@ uv run ruff format . && uv run ruff check . && uv run mypy # must be uv run pytest --cov # with branch coverage (config in pyproject.toml) ``` -CI runs lint, format check, and mypy, plus tests on ubuntu/macos x Python 3.10–3.14 against +CI runs lint, format check, and mypy, plus tests on ubuntu/macos x Python 3.11–3.14 against qsv, qsvlite, and qsvdp (Linux only). -On Windows (Python 3.10–3.14) it runs the full suite against qsv, plus the integration tests +On Windows (Python 3.11–3.14) it runs the full suite against qsv, plus the integration tests against qsvlite. Windows teardown uses Job Objects via ctypes (`_ProcessTree` in `_process.py`); type-check it with `uv run mypy --platform win32` (the lint job runs both). Coverage from every test job is merged on Codecov (`codecov.yml`): the total may not drop @@ -72,7 +72,8 @@ below the base commit's, and new or changed lines need 100% coverage. - `Qsv` and `AsyncQsv` are mirrored by hand. Apply every change to both. In `AsyncQsv`, `capabilities` and `version` are coroutines, not properties. -- Keep it dependency-free and 3.10-compatible (`from __future__ import annotations`). +- Keep it dependency-free and 3.11-compatible + (`from __future__ import annotations`). - mypy is strict and covers `tests/` too. Ruff line length is 100. - New error kind: add a subclass in `errors.py`, map it in `_KIND_TO_CLASS` (and `_EXIT_CODE_TO_CLASS` if it has its own exit code), export it from `__init__.py`, and update diff --git a/README.md b/README.md index a3a9338..ea7caf1 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,15 @@ subprocess pitfalls: `qsv --capabilities`. - **Secrets stay off the command line.** LLM settings for `describegpt` are passed as environment variables, so an API key never shows up in `ps` output. -- **Sync and asyncio** clients with the same API. No runtime dependencies. Python 3.10+ on +- **Sync and asyncio** clients with the same API. No runtime dependencies. Python 3.11+ on Linux, macOS and Windows. - **A local daemon, `qsv-clientd`,** that runs qsv for your own processes and lets a Data Schematic from `qsv viz smart` work with its live data, with an optional system tray to run and control it. See [The qsv-clientd daemon](#the-qsv-clientd-daemon). -qsv itself is not bundled, and **qsv 25.0.0 or later** is required. Install it from the -[qsv releases](https://github.com/dathere/qsv/releases) or a +qsv itself is not bundled, and **qsv 25.0.0 or later** is required; live Data Schematics +need **qsv 27.0.0 or later** (with an older qsv, `qsv-clientd serve` and the tray say so). +Install it from the [qsv releases](https://github.com/dathere/qsv/releases) or a [package manager](https://github.com/dathere/qsv#installation-options). ## Install @@ -128,17 +129,24 @@ client per call costs nothing extra after the first. `qsv-clientd` (installed with the package) is a per-user daemon that runs qsv for local processes, through `DaemonClient` / `AsyncDaemonClient` (the same `run()` as `Qsv`), and for -open **Data Schematics** from `qsv viz smart` (a qsv newer than 26.0.0). Once you approve a +open **Data Schematics** from `qsv viz smart` (qsv 27.0.0 or later). Once you approve a schematic with a pairing code, it can drill into the rows behind a chart, refresh its statistics, re-render itself, run read-only queries and fetch the latest data. Large results are paged from the daemon, so every row is reachable. ```bash qsv-clientd install # or: qsv-clientd serve -qsv-clientd register data.csv --schematic data.html +qsv-clientd viz data.csv # render, register and open data.html +qsv-clientd viz https://data.example.gov/dataset/housing/resource/ qsv-clientd approve K7Q-4MZ # the code the schematic shows ``` +`viz` with a URL (an http(s) download link, a CKAN resource page, or `ckan://`) +downloads the data first and remembers the URL, so Fetch latest gets the current data. An +existing schematic can be registered with `qsv-clientd register data.csv --schematic +data.html`. Settings go in `daemon.toml` (where it lives is in the doc); the tray's Open +config… creates it with every setting documented and commented out. + ```python from qsv_client.daemon import DaemonClient diff --git a/docs/DAEMON.md b/docs/DAEMON.md index 6500ca8..f81e06a 100644 --- a/docs/DAEMON.md +++ b/docs/DAEMON.md @@ -12,7 +12,7 @@ callers: the latest data from its source URL or PID. Without the daemon the schematic works exactly as before. -It needs nothing beyond the Python standard library, and runs on Python 3.10+ like the rest +It needs nothing beyond the Python standard library, and runs on Python 3.11+ like the rest of the package. ## Running it @@ -39,7 +39,7 @@ closes it, and `client()` returns a `DaemonClient` for it. A host starts once: t stop it and build a new `Daemon` and `DaemonHost`. Services don't read your shell profile, so `install` finds qsv the way the daemon would -(`binary` in `daemon.json`, then `$QSV_BIN`, then your PATH) and writes that absolute path +(`binary` in `daemon.toml`, then `$QSV_BIN`, then your PATH) and writes that absolute path into the service as `QSV_BIN`. It fails if no qsv is found. Run `install` again after moving qsv. Other `QSV_*` settings in your shell are not copied, because the service file is world-readable and they can hold secrets such as `QSV_LLM_APIKEY`. Set those in the @@ -47,9 +47,11 @@ service's environment yourself if runs through the daemon need them. A Windows s task gets your user environment variables, as set with `setx`, not those of the current shell. -Configuration is optional: `daemon.json` in the config directory +Configuration is optional: `daemon.toml` in the config directory (`~/.config/qsv-client/` on Linux, `~/Library/Application Support/qsv-client/` on macOS, -`%APPDATA%\qsv-client\` on Windows). Keys: `binary`, `port` (default `14470`; `0` turns the +`%APPDATA%\qsv-client\` on Windows), one `key = value` per line, with `#` comments. The +tray's **Open config…** creates it with every setting commented out, each with its default +and the values it takes. Keys: `binary`, `port` (default `14470`; `0` turns the browser listener off), `socket`, `max_jobs`, `max_timeout`, `inherit_env`, `kill_grace`, `browser`, `browser_origins` (default `["null"]`), `browser_timeout`, `browser_max_output`, `browser_max_result`, `browser_max_render`, `token_ttl`, `allow_fetch`, `fetch_max_bytes` @@ -71,7 +73,7 @@ when a schematic is waiting to be paired, red on an error. The menu shows the st version, running jobs and datasets, and lets you start, stop and restart the daemon. - **Hosted:** when no daemon is running, the tray runs one itself, on a background thread, - with your `daemon.json`. Quitting the tray stops it. Restart reads `daemon.json` again, and + with your `daemon.toml`. Quitting the tray stops it. Restart reads `daemon.toml` again, and unpairs every open schematic, since tokens are kept in memory. - **Attached:** when a daemon is already running (the service `install` set up, or `qsv-clientd serve`), the tray only shows it. Quitting leaves it running. If that daemon @@ -112,8 +114,9 @@ it went. A fetch refused because the new data lacks some of the dataset's column **Fetch latest data, accepting missing columns** next. **Revoke all tokens ▸ Yes** unpairs every open schematic. -**Files.** **Open config…** opens the default `daemon.json` in the config directory (not a -`--config` file a foreground `serve` may use), creating it as `{}` if it's missing, and says +**Files.** **Open config…** opens the default `daemon.toml` in the config directory (not a +`--config` file a foreground `serve` may use), creating it if it's missing with every setting +commented out, so nothing changes until you uncomment one, and says how changes apply: **Restart** when the tray hosts the daemon, otherwise a restart of the service or of `serve`. **Open log…** opens the tray's log, which a daemon it hosts writes to as well, or the launchd service's `~/Library/Logs/qsv-clientd.log`. For a systemd service the @@ -309,7 +312,7 @@ size or modification time, as with an edit or a fetch with `--allow-replace`. A a registered dataset by it: ```html - ``` diff --git a/pyproject.toml b/pyproject.toml index 4d054c4..964cd0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "hatchling.build" [project] name = "qsv-client" -version = "0.5.0" -description = "Run the qsv CSV toolkit (25.0.0+) from Python: timeouts that kill the whole process tree, typed errors, capability detection, and a local daemon (with an optional system tray) for live Data Schematics." +version = "0.6.0" +description = "Run the qsv CSV toolkit (25.0.0+) from Python: timeouts that kill the whole process tree, typed errors, capability detection, and a local daemon (with an optional system tray) for live Data Schematics (qsv 27.0.0+)." readme = "README.md" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [{ name = "datHere" }] keywords = ["qsv", "csv", "data-wrangling", "subprocess"] classifiers = [ @@ -27,7 +27,7 @@ dependencies = [] [project.optional-dependencies] # the system tray for qsv-clientd: `pip install 'qsv-client[tray]'` -tray = ["pystray>=0.19.5,<0.20", "Pillow>=10"] +tray = ["pystray>=0.19.5,<0.20", "Pillow>=13"] [project.scripts] qsv-clientd = "qsv_client.daemon.__main__:main" @@ -87,7 +87,7 @@ ignore_missing_imports = true [tool.ruff] line-length = 100 -target-version = "py310" +target-version = "py311" [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"] diff --git a/src/qsv_client/__init__.py b/src/qsv_client/__init__.py index 8a380a5..90b38e3 100644 --- a/src/qsv_client/__init__.py +++ b/src/qsv_client/__init__.py @@ -25,7 +25,7 @@ QsvWriteError, ) -__version__ = "0.5.0" +__version__ = "0.6.0" __all__ = [ "DEFAULT_BINARIES", diff --git a/src/qsv_client/client.py b/src/qsv_client/client.py index 1f5a1f7..ec36112 100644 --- a/src/qsv_client/client.py +++ b/src/qsv_client/client.py @@ -632,7 +632,7 @@ async def _probe(self, timeout: float | None = None) -> Capabilities: lock = _async_probe_lock(key) try: await asyncio.wait_for(lock.acquire(), limit) - except asyncio.TimeoutError: + except TimeoutError: raise _probe_wait_timeout(self.binary, limit) from None try: cached = _caps_cache.get(key) diff --git a/src/qsv_client/daemon/__main__.py b/src/qsv_client/daemon/__main__.py index 149512b..7d3049c 100644 --- a/src/qsv_client/daemon/__main__.py +++ b/src/qsv_client/daemon/__main__.py @@ -34,9 +34,9 @@ def _parser() -> argparse.ArgumentParser: sub = p.add_subparsers(dest="cmd", required=True) s = sub.add_parser("serve", help="run the daemon in the foreground") - s.add_argument("--config", type=Path, help="config file (default: daemon.json)") + s.add_argument("--config", type=Path, help="config file (default: daemon.toml)") s.add_argument("--port", type=int, help="loopback port for browsers (0 disables)") - s.add_argument("--binary", help="qsv binary to use (overrides daemon.json)") + s.add_argument("--binary", help="qsv binary to use (overrides daemon.toml)") s.add_argument("--log-file", type=Path, help="also log to this file (rotated at 5 MB, 3 kept)") s.add_argument("-v", "--verbose", action="store_true") diff --git a/src/qsv_client/daemon/client.py b/src/qsv_client/daemon/client.py index 4ab4c72..126ad3a 100644 --- a/src/qsv_client/daemon/client.py +++ b/src/qsv_client/daemon/client.py @@ -77,7 +77,7 @@ def headers(self, body: bytes) -> dict[str, str]: def _configured_socket() -> Path: - """The socket the daemon was configured with (daemon.json), else the default.""" + """The socket the daemon was configured with (daemon.toml), else the default.""" try: return DaemonConfig.load().socket_path() except (OSError, ValueError, TypeError): @@ -304,7 +304,7 @@ async def request( status, data = await asyncio.wait_for(self._exchange(method, path, payload), wait) except (FileNotFoundError, ConnectionRefusedError) as exc: raise QsvDaemonUnavailable(f"qsv-clientd is not running at {t.describe()}") from exc - except asyncio.TimeoutError: + except TimeoutError: raise QsvDaemonError("qsv-clientd did not answer in time") from None except OSError as exc: raise QsvDaemonError(_os_error_message(t, exc)) from exc diff --git a/src/qsv_client/daemon/config.py b/src/qsv_client/daemon/config.py index 0584a04..e7d758c 100644 --- a/src/qsv_client/daemon/config.py +++ b/src/qsv_client/daemon/config.py @@ -1,19 +1,41 @@ -"""Where qsv-clientd keeps its files, and its JSON configuration.""" +"""Where qsv-clientd keeps its files, and its TOML configuration.""" from __future__ import annotations import json import os import sys +import tomllib from collections.abc import Callable from dataclasses import dataclass, field, fields from pathlib import Path +from ..capabilities import parse_version + DEFAULT_PORT = 14470 """Loopback port for browsers (Data Schematics). qsv pro's API uses 14462.""" API_VERSION = 1 +LIVE_SCHEMATICS_QSV = "27.0.0" +"""The first qsv whose Data Schematics connect to qsv-clientd.""" + + +def schematics_notice(qsv_version: object) -> str | None: + """Why schematics won't go live with this qsv, or None when they will (or it can't tell).""" + if not isinstance(qsv_version, str): + return None + try: + older = parse_version(qsv_version) < parse_version(LIVE_SCHEMATICS_QSV) + except ValueError: + return None + if not older: + return None + return ( + f"qsv {qsv_version} writes Data Schematics that can't connect to qsv-clientd. " + f"Live schematics need qsv {LIVE_SCHEMATICS_QSV} or later." + ) + def _home_override() -> Path | None: # one directory for everything; used by tests and for running several daemons side by side @@ -21,6 +43,9 @@ def _home_override() -> Path | None: return Path(home) if home else None +CONFIG_FILE = "daemon.toml" + + def config_dir() -> Path: if (home := _home_override()) is not None: return home @@ -64,10 +89,15 @@ def socket_path_error(path: str | os.PathLike[str], exc: OSError) -> str | None: return ( f"the socket path {os.fspath(path)} is too long for a Unix socket " f"({len(os.fsencode(path))} bytes); set QSV_CLIENTD_SOCKET, or `socket` in " - "daemon.json, to a shorter path" + f"{CONFIG_FILE}, to a shorter path" ) +def config_path() -> Path: + """The default config file, ``daemon.toml`` in :func:`config_dir`.""" + return config_dir() / CONFIG_FILE + + def local_token_path() -> Path: return runtime_dir() / "local-token" @@ -118,7 +148,7 @@ def _is_int(v: object) -> bool: @dataclass class DaemonConfig: - """Settings read from ``daemon.json`` in :func:`config_dir` (every key is optional).""" + """Settings read from :func:`config_path` (every key is optional).""" binary: str | None = None """qsv binary (or sandbox wrapper); default: ``$QSV_BIN``, then PATH, like ``Qsv``.""" @@ -150,12 +180,18 @@ class DaemonConfig: @classmethod def load(cls, path: Path | None = None) -> DaemonConfig: - path = path or config_dir() / "daemon.json" + path = path or config_path() if not path.exists(): return cls() - data = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(data, dict): - raise ValueError(f"{path}: expected a JSON object") + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + if path.suffix.lower() == ".json": # a config from before 0.6.0 + raise ValueError( + f"{path}: the config is TOML now ({CONFIG_FILE}, one `key = value` per " + f"line), not JSON: {exc}" + ) from None + raise ValueError(f"{path}: {exc}") from None known = {f.name: f for f in fields(cls)} unknown = sorted(set(data) - set(known)) if unknown: @@ -168,3 +204,93 @@ def load(cls, path: Path | None = None) -> DaemonConfig: def socket_path(self) -> Path: return Path(self.socket) if self.socket else default_socket_path() + + +# what config_template says about each setting, and an example for those without a default +_HELP: dict[str, tuple[str, str | None]] = { + "binary": ( + "The qsv binary (or a sandbox wrapper): a path, or a name to look up on PATH.\n" + "Default: $QSV_BIN, then qsv on PATH.", + '"/usr/local/bin/qsv"', + ), + "port": ( + "Loopback TCP port for Data Schematics (and local clients on Windows).\n" + "0 turns the browser listener off.", + None, + ), + "socket": ( + "Unix socket path for local clients (macOS and Linux). Default: qsvd.sock in the\n" + "runtime directory. It must fit a socket address: about 104 bytes on macOS, 108 on Linux.", + '"/path/to/qsvd.sock"', + ), + "max_jobs": ( + "How many qsv runs at once, at least 1. Default: half your CPU cores.", + None, + ), + "max_timeout": ( + "Seconds. The longest a local run may take, and its timeout when it sets none.", + None, + ), + "inherit_env": ( + "true: qsv runs get the daemon's environment. false: they start from an empty one.", + None, + ), + "kill_grace": ( + "Seconds between asking a timed-out run to stop and killing it.", + None, + ), + "browser": ("true or false: let paired Data Schematics use the daemon.", None), + "browser_origins": ( + 'Origins allowed to pair, such as "https://example.org".\n' + '"null" is what a schematic opened as a file (file://) sends.', + None, + ), + "browser_timeout": ("Seconds. How long a qsv run started for a schematic may take.", None), + "browser_max_output": ( + "Bytes. The most output a schematic gets back from one run (default 4 MiB).", + None, + ), + "browser_max_result": ( + "Bytes. The largest result a schematic can read page by page (default 1 GiB).", + None, + ), + "browser_max_render": ( + "Bytes. The largest re-rendered schematic sent back to a page (default 64 MiB).", + None, + ), + "token_ttl": ("Seconds a schematic stays paired (default 8 hours).", None), + "allow_fetch": ( + "true or false: let paired schematics ask the daemon to download the latest data.", + None, + ), + "fetch_max_bytes": ( + "Bytes. The largest download a fetch accepts (default 10 GiB).", + None, + ), + "fetch_timeout": ("Seconds. The longest one fetch's download may take.", None), +} + + +def _toml_value(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) + return json.dumps(value) # a string or a list of strings: valid TOML too + + +def config_template() -> str: + """A ``daemon.toml`` with every setting commented out: its default (or an example) and + the values it takes. Lines left commented keep following the daemon's own defaults.""" + defaults = DaemonConfig() + lines = [ + "# qsv-clientd settings. Every setting is optional: one left commented out keeps its", + '# default. To change one, delete the "# " before it, then restart the daemon.', + "# More: https://github.com/dathere/qsv-client/blob/main/docs/DAEMON.md", + ] + for f in fields(DaemonConfig): + help_text, example = _HELP[f.name] + default = getattr(defaults, f.name) + value = example if default is None else _toml_value(default) + lines += ["", *(f"# {line}" for line in help_text.splitlines()), f"# {f.name} = {value}"] + return "\n".join(lines) + "\n" diff --git a/src/qsv_client/daemon/fetch.py b/src/qsv_client/daemon/fetch.py index 7b3c218..78aa2dc 100644 --- a/src/qsv_client/daemon/fetch.py +++ b/src/qsv_client/daemon/fetch.py @@ -138,6 +138,8 @@ async def download( if exc.code == "qsv_timeout": raise _timed_out(timeout) from None raise + # qsv puts a new file at --output with the umask's mode (often 0644), not our 0600 + incoming.chmod(0o600) # qsv downloads it, so the size can only be checked once it is here if incoming.stat().st_size > max_bytes: raise _too_large(max_bytes) diff --git a/src/qsv_client/daemon/server.py b/src/qsv_client/daemon/server.py index c1d001a..5f26d69 100644 --- a/src/qsv_client/daemon/server.py +++ b/src/qsv_client/daemon/server.py @@ -52,6 +52,7 @@ ensure_private_dir, local_token_path, runtime_dir, + schematics_notice, socket_path_error, state_dir, write_private_file, @@ -130,6 +131,9 @@ def socket_path(self) -> Path: async def start(self) -> None: caps = await self._qsv.capabilities() log.info("qsv-clientd %s using %s %s", __version__, caps.binary, caps.version) + notice = schematics_notice(caps.version) + if notice: + log.warning("%s", notice) ensure_private_dir(self.home) # bind everything before publishing the token: a second instance that fails to bind # must not replace the running daemon's credentials @@ -291,13 +295,13 @@ async def _serve_connection( try: try: request = await asyncio.wait_for(read_head(reader), _HEAD_TIMEOUT) - except asyncio.TimeoutError: + except TimeoutError: raise HttpError(408, "timeout", "request head not received in time") from None tier = self._tier(request, transport) limit = _LOCAL_BODY_LIMIT if tier == "local" else _BROWSER_BODY_LIMIT try: await asyncio.wait_for(read_body(reader, request, limit), _BODY_TIMEOUT) - except asyncio.TimeoutError: + except TimeoutError: raise HttpError(408, "timeout", "request body not received in time") from None if request.method == "OPTIONS": response = Response(204) @@ -329,7 +333,7 @@ async def _serve_connection( # Stay in _writers until done, so a shutdown that starts meanwhile can abort us try: await asyncio.wait_for(writer.wait_closed(), _FLUSH_TIMEOUT) - except (asyncio.TimeoutError, ConnectionError): + except (TimeoutError, ConnectionError): writer.transport.abort() except BaseException: # cancelled by shutdown: don't leave the transport flushing writer.transport.abort() @@ -995,7 +999,7 @@ async def _job(self, wait: float | None = None) -> Any: else: try: await asyncio.wait_for(self.jobs.acquire(), wait) - except asyncio.TimeoutError: + except TimeoutError: raise QsvTimeout( f"timed out after {wait}s waiting for one of the daemon's " f"{self.config.max_jobs} job slots; qsv did not run", diff --git a/src/qsv_client/daemon/service.py b/src/qsv_client/daemon/service.py index 7801252..a074aca 100644 --- a/src/qsv_client/daemon/service.py +++ b/src/qsv_client/daemon/service.py @@ -20,7 +20,7 @@ def _command(binary: str) -> list[str]: exe = shutil.which("qsv-clientd") base = [exe] if exe else [sys.executable, "-m", "qsv_client.daemon"] - # --binary overrides daemon.json's `binary`, which could be a bare name the service's + # --binary overrides daemon.toml's `binary`, which could be a bare name the service's # restricted PATH can't resolve (an explicit binary also outranks QSV_BIN) return [*base, "serve", "--binary", binary] @@ -29,7 +29,7 @@ def service_environment() -> dict[str, str]: """What the service needs from this shell, which launchd/systemd won't otherwise give it. Services don't read shell profiles (launchd's PATH is just /usr/bin:/bin:/usr/sbin:/sbin), - so qsv is resolved now, the way the daemon would (daemon.json ``binary``, ``$QSV_BIN``, + so qsv is resolved now, the way the daemon would (daemon.toml ``binary``, ``$QSV_BIN``, PATH), and pinned as QSV_BIN. Raises QsvNotFound if there is no qsv to pin. Other QSV_* settings are deliberately not copied: the service file is world-readable and they can hold secrets such as QSV_LLM_APIKEY. diff --git a/src/qsv_client/tray/__main__.py b/src/qsv_client/tray/__main__.py index f5bf6d1..14e4480 100644 --- a/src/qsv_client/tray/__main__.py +++ b/src/qsv_client/tray/__main__.py @@ -22,7 +22,7 @@ def _parser() -> argparse.ArgumentParser: prog=TITLE, description="A system tray icon that runs and controls qsv-clientd." ) p.add_argument("-v", "--verbose", action="store_true", help="log debug messages too") - p.add_argument("--binary", help="qsv binary to use (overrides daemon.json)") + p.add_argument("--binary", help="qsv binary to use (overrides daemon.toml)") login = p.add_mutually_exclusive_group() login.add_argument( "--enable-autostart", action="store_true", help="start the tray at login, then exit" @@ -49,7 +49,7 @@ def main(argv: list[str] | None = None) -> int: # before the checks below: writing a login item needs neither the extra nor the lock try: done = autostart.enable() if ns.enable_autostart else autostart.disable() - except Exception as exc: # no qsv to pin, a bad daemon.json, a permission error + except Exception as exc: # no qsv to pin, a bad daemon.toml, a permission error _message_box(f"couldn't change starting at login: {exc}") return 1 _message_box(done) diff --git a/src/qsv_client/tray/autostart.py b/src/qsv_client/tray/autostart.py index 697f8db..8fc3764 100644 --- a/src/qsv_client/tray/autostart.py +++ b/src/qsv_client/tray/autostart.py @@ -102,7 +102,7 @@ def enable() -> str: path = _plist_path() path.parent.mkdir(parents=True, exist_ok=True) # not kept alive, so Quit stays quit; written, not loaded, or a second tray starts now - # and pass it as --binary too: daemon.json's `binary` may be a bare name, which would + # and pass it as --binary too: daemon.toml's `binary` may be a bare name, which would # outrank QSV_BIN and which launchd's PATH can't resolve (as `qsv-clientd install`) argv = [*command(), "--binary", env["QSV_BIN"]] path.write_text( diff --git a/src/qsv_client/tray/controller.py b/src/qsv_client/tray/controller.py index b60c380..4e2587d 100644 --- a/src/qsv_client/tray/controller.py +++ b/src/qsv_client/tray/controller.py @@ -21,7 +21,13 @@ from ..daemon import service from ..daemon.client import DaemonClient -from ..daemon.config import DaemonConfig, config_dir, log_path +from ..daemon.config import ( + DaemonConfig, + config_path, + config_template, + log_path, + schematics_notice, +) from ..daemon.host import DaemonHost from ..daemon.server import Daemon from ..errors import QsvDaemonError @@ -37,7 +43,7 @@ def load_daemon(binary: str | None = None) -> Daemon: - """A daemon from daemon.json, read afresh each time so a restart picks up edits. + """A daemon from daemon.toml, read afresh each time so a restart picks up edits. ``binary`` overrides its ``binary``, as `qsv-clientd serve --binary` does.""" config = DaemonConfig.load() if binary is not None: @@ -75,6 +81,7 @@ def __init__( self.notify: Notifier = nothing """Shows a desktop notification, on the worker thread.""" self._announced: set[str] = set() + self._qsv_noted: object = None self._state = TrayState() self._lock = threading.Lock() self._emit_lock = threading.Lock() @@ -218,6 +225,17 @@ def _announce(self, pairings: tuple[Pending, ...]) -> None: ) self._announced = codes + def _note_qsv(self, status: Mapping[str, Any]) -> None: + """Say once per qsv version when its schematics can't go live.""" + qsv = status.get("qsv") + version = qsv.get("version") if isinstance(qsv, Mapping) else None + if version == self._qsv_noted: + return + self._qsv_noted = version + notice = schematics_notice(version) + if notice: + self.notify("Data Schematics won't go live", notice) + def _attached(self, client: DaemonClient) -> None: status = self._status(client) pairings = self._pairings(client, status) @@ -233,6 +251,7 @@ def _attached(self, client: DaemonClient) -> None: error=None, ) self._announce(pairings) + self._note_qsv(status) log.info("attached to qsv-clientd (pid %s)", status.get("pid")) def _start(self) -> None: @@ -255,6 +274,7 @@ def _start(self) -> None: mode=Mode.HOSTED, status=status, pairings=pairings, datasets=datasets, service=None ) self._announce(pairings) + self._note_qsv(status) log.info("hosting qsv-clientd") def _stop_host(self) -> None: @@ -476,15 +496,15 @@ def _open(self, path: Path) -> bool: return True def _open_config(self) -> None: - """Open the default daemon.json, creating it as ``{}`` (the only safe seed: unknown - keys are errors) so there is something to edit.""" - path = config_dir() / "daemon.json" + """Open the default daemon.toml, creating it with every setting commented out, so + there is something to edit and nothing changes until a line is uncommented.""" + path = config_path() try: if not path.exists(): path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("{}\n", encoding="utf-8") + path.write_text(config_template(), encoding="utf-8") except OSError as exc: # a read-only config directory: no reason to show an error state - self.notify("Couldn't create daemon.json", str(exc)) + self.notify(f"Couldn't create {path.name}", str(exc)) return if not self._open(path): return @@ -493,7 +513,7 @@ def _open_config(self) -> None: hint = _APPLY_TO_SERVICE.format(state.service) else: hint = _APPLY_CONFIG.get(state.mode, _APPLY_ON_START) - self.notify("Editing daemon.json", hint) + self.notify(f"Editing {path.name}", hint) def _open_log(self) -> None: state = self.state @@ -520,7 +540,7 @@ def _toggle_autostart(self) -> None: log.info(self._disable_autostart()) else: log.info(self._enable_autostart()) - except Exception as exc: # no qsv to pin, a bad daemon.json, a permission error + except Exception as exc: # no qsv to pin, a bad daemon.toml, a permission error log.warning("changing Start at login failed", exc_info=True) self.notify("Couldn't change Start at login", str(exc) or type(exc).__name__) self._read_autostart() diff --git a/tests/test_daemon.py b/tests/test_daemon.py index c4a066b..6deef83 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -507,7 +507,7 @@ def test_client_uses_configured_socket( ) -> None: home = Path(os.environ["QSV_CLIENTD_HOME"]) sock = home / "custom.sock" - (home / "daemon.json").write_text(json.dumps({"socket": str(sock)})) + (home / "daemon.toml").write_text(f"socket = {json.dumps(str(sock))}\n") start_daemon(_script(fake_qsv, ECHO), socket=str(sock)) if sys.platform != "win32": assert DaemonClient()._target.socket_path == str(sock) diff --git a/tests/test_daemon_client_edges.py b/tests/test_daemon_client_edges.py index c63662c..ed20e51 100644 --- a/tests/test_daemon_client_edges.py +++ b/tests/test_daemon_client_edges.py @@ -126,7 +126,7 @@ def test_runtime_port_ignores_bad_runtime_info(home: Path, content: str) -> None def test_malformed_config_falls_back_to_the_default_socket(home: Path) -> None: - (home / "daemon.json").write_text("{not json") + (home / "daemon.toml").write_text("port = = 1") assert _configured_socket() == default_socket_path() diff --git a/tests/test_daemon_config_service.py b/tests/test_daemon_config_service.py index 7559441..0b19b81 100644 --- a/tests/test_daemon_config_service.py +++ b/tests/test_daemon_config_service.py @@ -1,4 +1,4 @@ -"""Where qsv-clientd keeps its files on each platform, daemon.json, and the service installer. +"""Where qsv-clientd keeps its files on each platform, daemon.toml, and the service installer. Nothing real is touched: ``Path.home`` points into tmp_path, ``sys.platform`` is patched to each supported OS, and launchctl/systemctl calls are recorded instead of run. @@ -7,10 +7,12 @@ from __future__ import annotations import errno +import json import os import shutil import subprocess import sys +from dataclasses import fields from pathlib import Path from typing import Any @@ -141,20 +143,27 @@ def test_write_private_file_is_owner_only(tmp_path: Path) -> None: assert target.parent.stat().st_mode & 0o777 == 0o700 -@pytest.mark.parametrize("text", ["[]", '"port"', "14470", "null"]) -def test_config_must_be_an_object(tmp_path: Path, text: str) -> None: - path = tmp_path / "daemon.json" +@pytest.mark.parametrize("text", ["port = ", "port = 1\nport = 2", "{}", "[port"]) +def test_config_must_be_valid_toml(tmp_path: Path, text: str) -> None: + path = tmp_path / "daemon.toml" path.write_text(text) - with pytest.raises(ValueError, match="expected a JSON object"): + with pytest.raises(ValueError, match=r"daemon\.toml: "): + DaemonConfig.load(path) + + +def test_a_json_config_says_the_format_changed(tmp_path: Path) -> None: + path = tmp_path / "old.json" + path.write_text('{"port": 0}') + with pytest.raises(ValueError, match=r"old\.json: the config is TOML now \(daemon\.toml"): DaemonConfig.load(path) def test_config_rejects_unknown_and_mistyped_settings(tmp_path: Path) -> None: - path = tmp_path / "daemon.json" - path.write_text('{"port": 1, "prot": 2}') + path = tmp_path / "daemon.toml" + path.write_text("port = 1\nprot = 2\n") with pytest.raises(ValueError, match=r"unknown setting.*prot"): DaemonConfig.load(path) - for bad in ('{"port": true}', '{"port": "1"}', '{"browser_origins": ["null", 1]}'): + for bad in ("port = true", 'port = "1"', 'browser_origins = ["null", 1]', "[port]"): path.write_text(bad) with pytest.raises(ValueError, match="must be"): DaemonConfig.load(path) @@ -162,11 +171,39 @@ def test_config_rejects_unknown_and_mistyped_settings(tmp_path: Path) -> None: def test_config_defaults_when_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("QSV_CLIENTD_HOME", str(tmp_path)) + assert config.config_path() == tmp_path / "daemon.toml" assert DaemonConfig.load() == DaemonConfig() - (tmp_path / "daemon.json").write_text('{"port": 0}') + (tmp_path / "daemon.toml").write_text("port = 0\n") assert DaemonConfig.load().port == 0 +def test_the_config_template_changes_nothing(tmp_path: Path) -> None: + path = tmp_path / "daemon.toml" + path.write_text(config.config_template()) + assert DaemonConfig.load(path) == DaemonConfig() + + +def test_every_setting_in_the_template_is_valid_uncommented(tmp_path: Path) -> None: + template = config.config_template() + assert set(config._HELP) == {f.name for f in fields(DaemonConfig)} + settings = [ + line.removeprefix("# ") + for line in template.splitlines() + if line.startswith("# ") and " = " in line and line[2:].split(" = ")[0].isidentifier() + ] + assert [line.split(" = ")[0] for line in settings] == [f.name for f in fields(DaemonConfig)] + path = tmp_path / "daemon.toml" + path.write_text("\n".join(settings)) + loaded = DaemonConfig.load(path) + defaults = DaemonConfig() + for f in fields(DaemonConfig): + expected = getattr(defaults, f.name) + if expected is None: # no default to show: the template gives an example instead + assert getattr(loaded, f.name) == json.loads(config._HELP[f.name][1] or "") + else: + assert getattr(loaded, f.name) == expected + + # -- service install/uninstall -------------------------------------------------------------- @@ -379,3 +416,22 @@ def test_service_environment_copies_only_daemon_locations( monkeypatch.setenv("QSV_CLIENTD_SOCKET", "") # empty means unset assert "QSV_CLIENTD_SOCKET" not in service.service_environment() assert os.environ["QSV_LLM_APIKEY"] == "secret" + + +@pytest.mark.parametrize( + ("version", "noted"), + [ + ("26.0.0", True), + ("25.1.0-rc1", True), + ("27.0.0", False), + ("27.0.0-dev", False), + ("28.1.0", False), + ("not a version", False), + (None, False), + ], +) +def test_schematics_notice_names_qsv_27(version: object, noted: bool) -> None: + notice = config.schematics_notice(version) + assert (notice is not None) is noted + if notice: + assert f"qsv {version} " in notice and "qsv 27.0.0 or later" in notice diff --git a/tests/test_daemon_fetch.py b/tests/test_daemon_fetch.py index ba68327..8daea28 100644 --- a/tests/test_daemon_fetch.py +++ b/tests/test_daemon_fetch.py @@ -103,6 +103,7 @@ def urlopen(req: urllib.request.Request, timeout: float | None = None) -> Any: # a qsv with `get`: logs its argv, and `get cache-fetch ... --output ` writes the data GET_QSV = """ import json +import os if sys.argv[1:] == ["--capabilities"]: print(json.dumps({"binary": "qsv", "version": "26.0.0", "error_formats": ["text", "json"], "commands": ["get", "count", "slice"]})) @@ -110,7 +111,10 @@ def urlopen(req: urllib.request.Request, timeout: float | None = None) -> Any: with open(LOG, "a") as fh: fh.write(json.dumps(sys.argv[1:]) + "\\n") if sys.argv[1:3] == ["get", "cache-fetch"]: - open(sys.argv[sys.argv.index("--output") + 1], "w").write("name\\nfetched\\n") + # like qsv: a new file at --output, with the umask's mode, not the one already there + out = sys.argv[sys.argv.index("--output") + 1] + open(out + ".tmp", "w").write("name\\nfetched\\n") + os.replace(out + ".tmp", out) elif sys.argv[1] == "slice": print(open(sys.argv[-1]).readline().rstrip("\\n")) else: @@ -207,6 +211,19 @@ async def test_fetch_goes_through_qsv_get_when_available( assert _staging_files(daemon, ds) == [] +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes") +async def test_a_download_through_qsv_get_stays_private( + fake_qsv: Callable[[str], str], home: Path, tmp_path: Path +) -> None: + log = tmp_path / "argv.log" + daemon = _daemon(fake_qsv(f"LOG = {str(log)!r}\n" + GET_QSV), home) + staged, _via = await fetch.download( + daemon, "https://example.invalid/d.csv", tmp_path, name="qsv-clientd-x.csv" + ) + assert staged.read_text() == "name\nfetched\n" + assert staged.stat().st_mode & 0o777 == 0o600 # the data may be private + + @pytest.mark.parametrize("ctype", ["text/html", "text/html; charset=utf-8"]) async def test_download_refuses_a_web_page( fake_qsv: Callable[[str], str], home: Path, tmp_path: Path, web: _Routes, ctype: str diff --git a/tests/test_daemon_main.py b/tests/test_daemon_main.py index d395311..a7f1331 100644 --- a/tests/test_daemon_main.py +++ b/tests/test_daemon_main.py @@ -207,8 +207,8 @@ def test_serve_logs_to_a_file(fake_daemon: list[dict[str, Any]], tmp_path: Path) def test_serve_flags_override_the_config_file( fake_daemon: list[dict[str, Any]], tmp_path: Path ) -> None: - config = tmp_path / "custom.json" - config.write_text(json.dumps({"port": 1234, "binary": "/from/file/qsv"})) + config = tmp_path / "custom.toml" + config.write_text('port = 1234\nbinary = "/from/file/qsv"\n') assert cli.main(["serve", "--config", str(config), "-v"]) == 0 assert FakeDaemon.instances[-1].config.port == 1234 assert FakeDaemon.instances[-1].config.binary == "/from/file/qsv" @@ -222,8 +222,8 @@ def test_serve_flags_override_the_config_file( def test_serve_reports_a_bad_config( fake_daemon: list[dict[str, Any]], tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - config = tmp_path / "daemon.json" - config.write_text(json.dumps({"prot": 1})) + config = tmp_path / "daemon.toml" + config.write_text("prot = 1\n") assert cli.main(["serve", "--config", str(config)]) == 1 assert "unknown setting(s): prot" in capsys.readouterr().err assert FakeDaemon.instances == [] diff --git a/tests/test_daemon_server_edges.py b/tests/test_daemon_server_edges.py index 00c7ab5..918b866 100644 --- a/tests/test_daemon_server_edges.py +++ b/tests/test_daemon_server_edges.py @@ -1064,3 +1064,18 @@ def test_a_page_from_this_data_lends_its_source( ds_id = _register(d, data, schematic_path=str(schematic)) listed = {ds["id"]: ds for ds in _local(d, "GET", "/v1/datasets", None).json()} assert listed[ds_id]["source_url"] == "https://h.example/d.csv" + + +@pytest.mark.parametrize(("version", "noted"), [("26.0.0", True), ("27.0.0", False)]) +def test_serve_warns_when_qsv_schematics_cannot_go_live( + fake_qsv: Callable[[str], str], + start_daemon: StartDaemon, + caplog: pytest.LogCaptureFixture, + version: str, + noted: bool, +) -> None: + caps = json.dumps({"binary": "qsv", "version": version, "error_formats": ["text", "json"]}) + body = f'if sys.argv[1:] == ["--capabilities"]:\n print({caps!r})\n sys.exit(0)\n' + with caplog.at_level(logging.WARNING, logger="qsv_client.daemon"): + start_daemon(fake_qsv(body)) + assert any("can't connect" in r.getMessage() for r in caplog.records) is noted diff --git a/tests/test_daemon_units.py b/tests/test_daemon_units.py index 682987d..57ec11e 100644 --- a/tests/test_daemon_units.py +++ b/tests/test_daemon_units.py @@ -230,40 +230,42 @@ def test_registry_refuses_output_flag(tmp_path: Path) -> None: def test_config_rejects_unknown_keys(tmp_path: Path) -> None: - path = tmp_path / "daemon.json" - path.write_text('{"port": 1, "prot": 2}') + path = tmp_path / "daemon.toml" + path.write_text("port = 1\nprot = 2\n") with pytest.raises(ValueError, match="prot"): DaemonConfig.load(path) - path.write_text('{"port": 1234}') + path.write_text("port = 1234\n") assert DaemonConfig.load(path).port == 1234 @pytest.mark.parametrize( "setting", [ - '{"max_jobs": "2"}', - '{"max_timeout": "60"}', - '{"browser": 1}', - '{"port": true}', - '{"browser_origins": "null"}', - '{"binary": 5}', + 'max_jobs = "2"', + 'max_timeout = "60"', + "browser = 1", + "port = true", + 'browser_origins = "null"', + "binary = 5", + "max_jobs = 2.0", ], ) def test_config_rejects_wrong_types(tmp_path: Path, setting: str) -> None: - path = tmp_path / "daemon.json" + path = tmp_path / "daemon.toml" path.write_text(setting) with pytest.raises(ValueError, match="must be"): DaemonConfig.load(path) def test_config_accepts_every_field_type(tmp_path: Path) -> None: - path = tmp_path / "daemon.json" + path = tmp_path / "daemon.toml" path.write_text( - '{"binary": null, "port": 0, "max_timeout": 30, "kill_grace": 1.5, "browser": false,' - ' "browser_origins": ["null"]}' + 'binary = "qsv"\nport = 0\nmax_timeout = 30\nkill_grace = 1.5\nbrowser = false\n' + 'browser_origins = ["null"]\n' ) config = DaemonConfig.load(path) - assert config.max_timeout == 30 and config.kill_grace == 1.5 and config.browser is False + assert config.binary == "qsv" and config.max_timeout == 30 and config.kill_grace == 1.5 + assert config.browser is False # a new setting needs a type check, or loading it would raise KeyError from dataclasses import fields @@ -371,7 +373,7 @@ def test_service_overrides_configured_binary_and_paths( lite.chmod(lite.stat().st_mode | stat.S_IXUSR) home = tmp_path / "home" home.mkdir() - (home / "daemon.json").write_text('{"binary": "qsvlite"}') # a bare name + (home / "daemon.toml").write_text('binary = "qsvlite"\n') # a bare name monkeypatch.chdir(tmp_path) monkeypatch.setenv("QSV_CLIENTD_HOME", "home") # relative monkeypatch.setenv("PATH", str(lite.parent)) diff --git a/tests/test_tray_autostart.py b/tests/test_tray_autostart.py index d049907..7a9d3c8 100644 --- a/tests/test_tray_autostart.py +++ b/tests/test_tray_autostart.py @@ -53,7 +53,7 @@ def test_macos(home: Path, monkeypatch: pytest.MonkeyPatch) -> None: assert autostart.is_enabled() is True plist = plistlib.loads(path.read_bytes()) assert plist["Label"] == autostart.LABEL - # --binary too: a bare `binary` in daemon.json would outrank QSV_BIN on launchd's PATH + # --binary too: a bare `binary` in daemon.toml would outrank QSV_BIN on launchd's PATH assert plist["ProgramArguments"] == ["/opt/bin/qsv-clientd-tray", "--binary", "/opt/q s/qsv"] assert plist["EnvironmentVariables"] == {"QSV_BIN": "/opt/q s/qsv"} # launchd's PATH lacks qsv assert plist["KeepAlive"] is False and plist["RunAtLoad"] is True # Quit stays quit diff --git a/tests/test_tray_controller.py b/tests/test_tray_controller.py index 7a46a4b..1f54cfd 100644 --- a/tests/test_tray_controller.py +++ b/tests/test_tray_controller.py @@ -20,7 +20,7 @@ from daemon_helpers import DaemonThread from qsv_client.daemon.client import DaemonClient -from qsv_client.daemon.config import DaemonConfig +from qsv_client.daemon.config import DaemonConfig, config_template from qsv_client.daemon.host import DaemonHost from qsv_client.daemon.server import Daemon from qsv_client.errors import QsvDaemonError, QsvDaemonUnavailable, QsvNotFound @@ -39,9 +39,20 @@ def home(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: shutil.rmtree(path, ignore_errors=True) +def _qsv_reporting(version: str) -> str: + """A fake qsv body whose --capabilities reports ``version``.""" + caps = json.dumps({"binary": "qsv", "version": version, "error_formats": ["text", "json"]}) + return f""" + if sys.argv[1:] == ["--capabilities"]: + print({caps!r}) + sys.exit(0) + """ + + @pytest.fixture def make_daemon(home: Path, fake_qsv: Callable[[str], str]) -> Callable[[], Daemon]: - binary = fake_qsv("") + # the first qsv whose schematics go live: older ones add a notification + binary = fake_qsv(_qsv_reporting("27.0.0")) return lambda: Daemon(DaemonConfig(binary=binary), home=home, tcp_port=0) @@ -99,7 +110,7 @@ def test_hosts_a_daemon_when_none_is_running(make_controller: MakeController, ho c.on_change = seen.append state = _do(c, model.START) assert state.mode is Mode.HOSTED and state.status is not None - assert state.status["qsv"]["version"] == "25.0.0" + assert state.status["qsv"]["version"] == "27.0.0" assert [s.mode for s in seen] == [Mode.STARTING, Mode.HOSTED] assert DaemonClient().available() assert (home / "run" / "local-token").exists() @@ -364,11 +375,11 @@ def test_a_stop_that_timed_out_is_tried_again( assert c._host is None and not (home / "run" / "local-token").exists() -def test_the_default_daemon_reads_daemon_json(home: Path, fake_qsv: Callable[[str], str]) -> None: - (home / "daemon.json").write_text(json.dumps({"binary": fake_qsv(""), "port": 0})) +def test_the_default_daemon_reads_daemon_toml(home: Path, fake_qsv: Callable[[str], str]) -> None: + (home / "daemon.toml").write_text(f"binary = {json.dumps(fake_qsv(''))}\nport = 0\n") daemon = controller_mod.load_daemon() assert daemon.binary == fake_qsv("") and daemon.config.port == 0 - (home / "daemon.json").write_text(json.dumps({"binary": "qsv", "port": 0})) # bare name + (home / "daemon.toml").write_text('binary = "qsv"\nport = 0\n') # bare name assert controller_mod.load_daemon(fake_qsv("")).binary == fake_qsv("") # --binary wins @@ -380,6 +391,26 @@ def __call__(self, title: str, message: str) -> None: self.append((title, message)) +def test_a_qsv_older_than_27_is_noted_once( + make_controller: MakeController, home: Path, fake_qsv: Callable[[str], str] +) -> None: + binary = fake_qsv(_qsv_reporting("26.0.0")) + c = make_controller( + make_daemon=lambda: Daemon(DaemonConfig(binary=binary), home=home, tcp_port=0) + ) + notes = Notes() + c.notify = notes + _do(c, model.START) + _do(c, model.RESTART) + assert notes == [ + ( + "Data Schematics won't go live", + "qsv 26.0.0 writes Data Schematics that can't connect to qsv-clientd. " + "Live schematics need qsv 27.0.0 or later.", + ) + ] + + @pytest.fixture def data(tmp_path: Path) -> Path: path = tmp_path / "sales.csv" @@ -826,18 +857,19 @@ def test_open_config_creates_it_and_says_how_changes_apply( c.notify = notes c._set(**fields) c.submit(model.OPEN_CONFIG).result(60) - assert opened == [home / "daemon.json"] - assert (home / "daemon.json").read_text() == "{}\n" # unknown keys are errors: {} only + assert opened == [home / "daemon.toml"] + assert (home / "daemon.toml").read_text() == config_template() + assert DaemonConfig.load() == DaemonConfig() # all commented out: nothing changes ((title, message),) = notes - assert title == "Editing daemon.json" and hint in message + assert title == "Editing daemon.toml" and hint in message def test_open_config_keeps_an_existing_file(make_controller: MakeController, home: Path) -> None: - (home / "daemon.json").write_text('{"port": 0}') + (home / "daemon.toml").write_text("port = 0\n") c = make_controller() _opener(c) c.submit(model.OPEN_CONFIG).result(60) - assert (home / "daemon.json").read_text() == '{"port": 0}' + assert (home / "daemon.toml").read_text() == "port = 0\n" def test_a_file_that_wont_open_is_a_notification( @@ -853,7 +885,7 @@ def no_opener(path: Path) -> None: c._open_path = no_opener c.submit(model.OPEN_CONFIG).result(60) assert notes == [ - ("Couldn't open daemon.json", "[Errno 2] No such file or directory: 'xdg-open'") + ("Couldn't open daemon.toml", "[Errno 2] No such file or directory: 'xdg-open'") ] assert c.state.mode is Mode.STOPPED @@ -936,4 +968,4 @@ def read_only(self: Path, *args: Any, **kwargs: Any) -> None: c._set(mode=Mode.HOSTED) c.submit(model.OPEN_CONFIG).result(60) assert opened == [] and c.state.mode is Mode.HOSTED - assert notes[0][0] == "Couldn't create daemon.json" and "Permission denied" in notes[0][1] + assert notes[0][0] == "Couldn't create daemon.toml" and "Permission denied" in notes[0][1] diff --git a/tests/test_tray_open.py b/tests/test_tray_open.py index e628734..27bfc5e 100644 --- a/tests/test_tray_open.py +++ b/tests/test_tray_open.py @@ -53,9 +53,9 @@ def test_posix_openers( new_session: bool, ) -> None: monkeypatch.setattr(sys, "platform", platform) - _open.open_path(tmp_path / "daemon.json") + _open.open_path(tmp_path / "daemon.toml") (proc,) = popen - assert proc.argv == [*argv, str(tmp_path / "daemon.json")] + assert proc.argv == [*argv, str(tmp_path / "daemon.toml")] assert proc.kwargs["start_new_session"] is new_session assert proc.kwargs["stdout"] == subprocess.DEVNULL assert _reaped(proc) # no zombie left behind @@ -79,9 +79,9 @@ def no_association(path: Path) -> None: monkeypatch.setattr(os, "startfile", no_association, raising=False) monkeypatch.setattr(sys, "platform", "win32") - _open.open_path(tmp_path / "daemon.json") + _open.open_path(tmp_path / "daemon.toml") (proc,) = popen - assert proc.argv == ["notepad.exe", str(tmp_path / "daemon.json")] + assert proc.argv == ["notepad.exe", str(tmp_path / "daemon.toml")] assert proc.kwargs["start_new_session"] is False @@ -92,4 +92,4 @@ def missing(argv: list[str], **kwargs: Any) -> None: monkeypatch.setattr(subprocess, "Popen", missing) monkeypatch.setattr(sys, "platform", "linux") with pytest.raises(OSError): - _open.open_path(tmp_path / "daemon.json") + _open.open_path(tmp_path / "daemon.toml") diff --git a/uv.lock b/uv.lock index 81f0223..4b5fcab 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,9 @@ version = 1 -revision = 5 -requires-python = ">=3.10" +revision = 3 +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", - "python_full_version < '3.11'", + "python_full_version < '3.15'", ] [[package]] @@ -71,15 +70,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -95,20 +85,6 @@ version = "7.16.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, - { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, - { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, - { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, - { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, - { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, - { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, - { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, - { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, @@ -222,18 +198,6 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "iniconfig" version = "2.3.1" @@ -249,20 +213,6 @@ version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/77/900294b3c9a81415323d81b3f3f271a7f5d7ed9d01e824fc38ffa49470b6/librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6", size = 148668, upload-time = "2026-09-29T00:44:20.282Z" }, - { url = "https://files.pythonhosted.org/packages/55/3b/c301e8cc47758e4518b93e926a0111080155cc79fa03651d202ac2025065/librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978", size = 153562, upload-time = "2026-09-29T00:44:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/62/b1/4d5b98114a58bf17e3832cd33400e40f2582f68c2fb7ce25b4468cb1afd3/librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd", size = 494337, upload-time = "2026-09-29T00:44:23.096Z" }, - { url = "https://files.pythonhosted.org/packages/88/36/14223c9b0dc38982eb4f399732c8cb27b19c7e027ae0faef5ca51d08d19f/librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144", size = 485440, upload-time = "2026-09-29T00:44:24.555Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/d7155e6800d0c33e297e529b217649f7361d706491ebf9d2fc0fc627309e/librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671", size = 498363, upload-time = "2026-09-29T00:44:26.03Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ce/d5f1fb9f913ac0979d6fc5db9fb9c0bf28cc3470988528957922f15ed40c/librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262", size = 515453, upload-time = "2026-09-29T00:44:27.566Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d4/ca0fca1a27c26080d1a25b2b31f271e532078a5719878c3daca0c3d4c539/librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a", size = 509537, upload-time = "2026-09-29T00:44:29.14Z" }, - { url = "https://files.pythonhosted.org/packages/ee/c0/26807bf2221a53a2c66f2e23a5c4d57f1c0eaa4afed7cb81d841cf3d1903/librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c", size = 532541, upload-time = "2026-09-29T00:44:30.745Z" }, - { url = "https://files.pythonhosted.org/packages/0e/50/c329d509907e1bc061abeec3796f94d255d43ba25d28c3854d2138ed5ab2/librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726", size = 537059, upload-time = "2026-09-29T00:44:32.102Z" }, - { url = "https://files.pythonhosted.org/packages/84/d6/4571b9c9e65d98e5503734166dce2fd3e216f526dc3c59a3773faa8a60e3/librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a", size = 536614, upload-time = "2026-09-29T00:44:33.653Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d3/a2ed8a292e4ab7889792a9c3f4ad5d1caf6557afce7f6b452e77f8ed20b7/librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1", size = 517199, upload-time = "2026-09-29T00:44:35.115Z" }, - { url = "https://files.pythonhosted.org/packages/9e/24/276e5218b4b7adb6258a42b7403cb1b7cef25616238d0c2152288a9d2f53/librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143", size = 558746, upload-time = "2026-09-29T00:44:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5c/9156df81a1726e1efc9567379def2b8777a6d56f464d2f1b760bf951b8af/librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba", size = 104424, upload-time = "2026-09-29T00:44:38.123Z" }, - { url = "https://files.pythonhosted.org/packages/0b/62/4fd172cb3f49ab2cf5ef17de519a93cfb935202c1270ba9d44c6a64178c3/librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155", size = 124267, upload-time = "2026-09-29T00:44:39.455Z" }, { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, @@ -382,17 +332,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/0a/78c9ac93933e2ecd51d807021f78c03ffe98640f33951a8be159f5fd55bc/mypy-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffda5244fd1ad71a1e54405e35f50d09b80c3978efa120f58bd1252ae32c62d2", size = 14408618, upload-time = "2026-10-01T20:39:46.766Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4f/5f5f4429fd5a4218ce6268164a66afeed75c7e2ce5522a4d163faaa5fe85/mypy-2.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:615b03922d40e186fd1df73156473db0ba525c639bb4fd1cf28e1694879c9b23", size = 14552749, upload-time = "2026-10-01T20:40:08.325Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/fa1c319cd51db7a6027b46117460d5a361c6bba0c0f0f0670a2dab8ce2a5/mypy-2.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13fa24f439c0e48a290a3922fa14ccd22f2762bae99d2142931b3e40a9055080", size = 15505846, upload-time = "2026-10-01T20:39:15.475Z" }, - { url = "https://files.pythonhosted.org/packages/e2/5a/14c02690776fb04e9fa60b81173cb9ae6315e0745fad3813b303737f2f0c/mypy-2.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3adef556a19eb3b630bf86a79c29d7da3d61e541472d0d897ca01b171c0abf8f", size = 15823059, upload-time = "2026-10-01T20:39:35.429Z" }, - { url = "https://files.pythonhosted.org/packages/76/d8/d29afa819f8cb765018d6e2773e390a41ea4592f75abe417135969646857/mypy-2.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:1dc0f64b0a92ae27a49d2175f0bacfa56e15bdc5b420cf92e0c1f79292219cbb", size = 11312927, upload-time = "2026-10-01T20:39:28.509Z" }, - { url = "https://files.pythonhosted.org/packages/bc/c8/966dec4d80303855d824e6bf67fe1da812d88adfe4d75e24d8545dab4826/mypy-2.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a378fc15fb33e321f04652c166ce73eeb8833a97c3d218132844e938cd93220", size = 10879252, upload-time = "2026-10-01T20:38:41.545Z" }, { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, @@ -467,111 +410,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] -[[package]] -name = "pillow" -version = "12.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, - { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, - { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, - { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, - { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, - { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, - { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, - { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, - { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, - { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, - { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, - { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, - { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, - { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, - { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, - { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, - { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, - { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, - { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, - { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, - { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, - { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, - { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, - { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, - { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, - { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, - { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, - { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, - { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, - { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, - { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, - { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, - { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, - { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, - { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, - { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, - { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, - { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, - { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, - { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, - { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, - { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, - { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, -] - [[package]] name = "pillow" version = "13.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", -] sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6c/d7/af6af1b8cc55e07cb0489d5b0950bf5a9bb1461eeba825700b6d8d0e26ea/pillow-13.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb", size = 5592324, upload-time = "2026-10-16T22:15:24.672Z" }, @@ -682,7 +524,6 @@ version = "12.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a5/78/abc4ce5920305780aeb36b4067a86253378b36e29ba96673a3deb02eb03a/pyobjc_core-12.2.2.tar.gz", hash = "sha256:3906452339cd06a3bb07df103c2511d4cb0f7a22d8771c0b802eba15d9a642b6", size = 1067701, upload-time = "2026-08-11T19:43:39.059Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/1d/baf7197cee12f32a8eb9f8633093da1ec1ea702b0e1346bc1c7bfe022673/pyobjc_core-12.2.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:56c6c39f1de059fcbb174ebca5525505fc8feaa89be2a28c329bf09b6b25ee75", size = 6483051, upload-time = "2026-08-11T13:55:45.262Z" }, { url = "https://files.pythonhosted.org/packages/ce/8e/18284fec7913ef78b25a1c97f9689ebef98bc14038386191491516abeb25/pyobjc_core-12.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b9cdd686e32db8e451feb19f8a85bc4cd52c2893103881d04aca51e1f35371d1", size = 6481574, upload-time = "2026-08-11T14:13:27.153Z" }, { url = "https://files.pythonhosted.org/packages/86/b2/bbf7f049880ab40d110e66f25122342a1f6c98d6fe3c59bb98985503c660/pyobjc_core-12.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:122e6ad302a2abf5d4d4adb0156db751600ddf2768441696cba17b31323085e7", size = 6427637, upload-time = "2026-08-11T14:51:36.038Z" }, { url = "https://files.pythonhosted.org/packages/1b/ed/a8bf040caf3704023d74086b7fb96cf4ed2e844e24bd94e5248ba214b700/pyobjc_core-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:950bd2d9c74634398c4e3d24ef2f213d4e23d705083697464fa67afedc53c1ad", size = 6427113, upload-time = "2026-08-11T15:04:39.424Z" }, @@ -702,7 +543,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/dd/aba439652cae293a736680ef5ed5cc29419adbbac1c6d4555b910741d516/pyobjc_framework_cocoa-12.2.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5a751c8033a3b51f7996f0327e0675eb44dcfdfe7920fae01e3d78b662723fff", size = 387296, upload-time = "2026-08-11T19:32:40.684Z" }, { url = "https://files.pythonhosted.org/packages/f6/a7/370f12143661dff66f2c68a735938afab6530aa3b153f6a7a6f12b5eabab/pyobjc_framework_cocoa-12.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:851dca4c16e70b405e5cd5a8c166cf7c445ae54a4cdd95ce9a523803172f32d1", size = 387300, upload-time = "2026-08-11T19:32:42.043Z" }, { url = "https://files.pythonhosted.org/packages/fd/2f/b67e73d8bc367e03fe7861cd9c49fff9dcfa6db83bc0630c0adcfb25b7fa/pyobjc_framework_cocoa-12.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e106f395531e67694376b0f1184612cbeea3ec8b9bf56b55ef41d026171d2a2d", size = 388117, upload-time = "2026-08-11T19:32:43.161Z" }, { url = "https://files.pythonhosted.org/packages/db/e1/5d9b04ebb60042b9cb49adc2d33115e2f2c2e4ff7d548017bfaff8b7f536/pyobjc_framework_cocoa-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:600b1723184ca094931330e79355274949965460e23de38628d601b5a967baf9", size = 388181, upload-time = "2026-08-11T19:32:44.537Z" }, @@ -723,7 +563,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/35/b1/426a37c7ae37280b3ffca2571fb48f211946aee2f4ca31a603ed1943c4a7/pyobjc_framework_quartz-12.2.2.tar.gz", hash = "sha256:810f97b210cfd93704d240860286dfd6df09f9f1c52525fc5c2166723aea3f9e", size = 3218295, upload-time = "2026-08-11T19:45:15.189Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/94/2c9839958b0a2a040e22cca145040a8fc2612f099eda7524900d741f560d/pyobjc_framework_quartz-12.2.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d89a5f47c079b5c340d2b1cbb83eb6c4c92d4bb17cd4daf7d8c02c91a49f5399", size = 218007, upload-time = "2026-08-11T19:40:22.077Z" }, { url = "https://files.pythonhosted.org/packages/b9/e8/16d07170d4e1bd182a8e6084abdf283fac90979384b8f53107be9eb110e6/pyobjc_framework_quartz-12.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4b01e325b0cdc121e78730dde9756e971b23069bf141cd62efbcaac76d7b6dbb", size = 218008, upload-time = "2026-08-11T19:40:23.36Z" }, { url = "https://files.pythonhosted.org/packages/ed/e4/8be95d2ff850f82fb55b44c63333a00a920bf8a73642e7d9c2f3638a26d2/pyobjc_framework_quartz-12.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f668979d0c7320bf8f7ed6e030da578f93ab0f5dd619b295ec735cd8d5faa34", size = 219003, upload-time = "2026-08-11T19:40:24.425Z" }, { url = "https://files.pythonhosted.org/packages/bb/ae/b515852dbe491171f2f2e2eb7739588a5eb7f36720a739545337b8c0d706/pyobjc_framework_quartz-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0ec9751904ef975bf0789d760dc4fadcb400edc4ffe4a736eb54971968babe5c", size = 219404, upload-time = "2026-08-11T19:40:25.461Z" }, @@ -739,8 +578,7 @@ name = "pystray" version = "0.19.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pillow", version = "13.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, { name = "python-xlib", marker = "sys_platform == 'linux'" }, { name = "six" }, @@ -755,12 +593,10 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -772,7 +608,6 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -809,13 +644,12 @@ wheels = [ [[package]] name = "qsv-client" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } [package.optional-dependencies] tray = [ - { name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pillow", version = "13.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, { name = "pystray" }, ] @@ -830,7 +664,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "pillow", marker = "extra == 'tray'", specifier = ">=10" }, + { name = "pillow", marker = "extra == 'tray'", specifier = ">=13" }, { name = "pystray", marker = "extra == 'tray'", specifier = ">=0.19.5,<0.20" }, ] provides-extras = ["tray"]