From 2a7c7073d1789ac9d1eaba5a0b9f6bf413874e3c Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:46:05 -0400 Subject: [PATCH 01/10] chore(release): 0.6.0 Bump to 0.6.0 for qsv 27.0.0, which brings live Data Schematics. The docs now name qsv 27.0.0 or later for them, in place of "a qsv newer than 26.0.0". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++++++++++- README.md | 2 +- docs/DAEMON.md | 2 +- pyproject.toml | 4 ++-- src/qsv_client/__init__.py | 2 +- uv.lock | 2 +- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6168df6..899acbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ 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 a schematic remembers where its data came from, so Fetch latest +can get new data. + ### Added - **`qsv-clientd viz `** ([#34](https://github.com/dathere/qsv-client/pull/34)) downloads the data first, then renders, registers and opens @@ -24,6 +31,11 @@ All notable changes to this project are documented here. The format follows - `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)) +### Changed + +- 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 - `ckan://` sources were resolved against qsv's default CKAN site (data.dathere.com) unless @@ -278,7 +290,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/README.md b/README.md index a3a9338..da58134 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ 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. diff --git a/docs/DAEMON.md b/docs/DAEMON.md index 6500ca8..48e2f55 100644 --- a/docs/DAEMON.md +++ b/docs/DAEMON.md @@ -309,7 +309,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..60d078b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ 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"] 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/uv.lock b/uv.lock index 599ef71..efcbc1e 100644 --- a/uv.lock +++ b/uv.lock @@ -809,7 +809,7 @@ wheels = [ [[package]] name = "qsv-client" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } [package.optional-dependencies] From ab6b72ea5b3f604cc62d297c1e4d59ad25b9f795 Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:04:39 -0400 Subject: [PATCH 02/10] docs(changelog): qsv-clientd, not the page, remembers a viz download's source Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 899acbe..044099c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ All notable changes to this project are documented here. The format follows 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 a schematic remembers where its data came from, so Fetch latest -can get new data. +schematic in one step, and `qsv-clientd` remembers where the data came from, so Fetch latest +gets the current version. ### Added From 6056afacddf20d4c54282492035620a5a01e8752 Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:09:44 -0400 Subject: [PATCH 03/10] fix(daemon): data downloaded through qsv get stays owner-only `qsv get cache-fetch --output` puts a new file at the staging path with the umask's mode, so a `viz ` or `register --download` through a qsv with `get` left the data 0644 beside the schematic. It is made 0600 again before anything else. The test fake now replaces the file as qsv does, rather than truncating it, which had kept the mode and hidden this. Co-Authored-By: Claude Opus 5.5 --- src/qsv_client/daemon/fetch.py | 2 ++ tests/test_daemon_fetch.py | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) 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/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 From 459867fb76167d4b7ca2ff1cc7a34b9e13a3b2f4 Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:24:05 -0400 Subject: [PATCH 04/10] docs(changelog): qsv get's cache keeps a copy that follows the umask The downloaded file is owner-only, but a qsv with `get` also caches the data in ~/.qsv-cache (or QSV_CACHE_DIR) with its default modes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 044099c..5d56939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,10 @@ gets the current version. 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 From 74939f588bb176cd84a0d5befa3b8cf5c864427b Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:02:41 -0400 Subject: [PATCH 05/10] feat(daemon)!: daemon.toml replaces daemon.json, and Open config seeds a commented template JSON can't hold comments, so the tray's Open config could only seed `{}`. The config is now TOML (tomllib on 3.11+, tomli on 3.10). Open config creates daemon.toml with every setting commented out, each with its default and the values it takes, generated from DaemonConfig so the two can't drift. A daemon.json from 0.5.x is no longer read. Co-Authored-By: Claude Opus 5.5 --- .serena/memories/core.md | 2 +- .serena/memories/tech_stack.md | 2 +- CHANGELOG.md | 5 ++ CLAUDE.md | 6 +- README.md | 4 +- docs/DAEMON.md | 15 ++-- pyproject.toml | 2 +- src/qsv_client/daemon/__main__.py | 4 +- src/qsv_client/daemon/client.py | 2 +- src/qsv_client/daemon/config.py | 118 ++++++++++++++++++++++++++-- src/qsv_client/daemon/service.py | 4 +- src/qsv_client/tray/__main__.py | 4 +- src/qsv_client/tray/autostart.py | 2 +- src/qsv_client/tray/controller.py | 18 ++--- tests/test_daemon.py | 2 +- tests/test_daemon_client_edges.py | 2 +- tests/test_daemon_config_service.py | 48 ++++++++--- tests/test_daemon_main.py | 8 +- tests/test_daemon_units.py | 32 ++++---- tests/test_tray_autostart.py | 2 +- tests/test_tray_controller.py | 23 +++--- tests/test_tray_open.py | 10 +-- uv.lock | 6 +- 23 files changed, 236 insertions(+), 85 deletions(-) diff --git a/.serena/memories/core.md b/.serena/memories/core.md index cd33aa7..06fc350 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 (except `tomli` on 3.10, for daemon.toml). 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..bfbc574 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). +- No runtime dependencies — keep it that way (stdlib `subprocess`/`asyncio` only). Sole exception: `tomli` on Python 3.10, for reading daemon.toml (`tomllib` on 3.11+). - 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 5d56939..d0aaa68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ gets the current version. ### Changed +- **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. On + Python 3.10 this adds `tomli`, the only runtime dependency (3.11+ uses `tomllib`). - 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. diff --git a/CLAUDE.md b/CLAUDE.md index cfda716..1013d76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,8 @@ 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, except `tomli` on Python 3.10 (`tomllib` from 3.11) for the daemon's +config. Python 3.10+. ## Commands @@ -72,7 +73,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 (beyond `tomli` on 3.10) and 3.10-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 da58134..68a011a 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ 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 - Linux, macOS and Windows. +- **Sync and asyncio** clients with the same API. No runtime dependencies (on Python 3.10, `tomli`, to read + the daemon's config). Python 3.10+ 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). diff --git a/docs/DAEMON.md b/docs/DAEMON.md index 48e2f55..210a47f 100644 --- a/docs/DAEMON.md +++ b/docs/DAEMON.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 60d078b..179efcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Information Analysis", "Typing :: Typed", ] -dependencies = [] +dependencies = ["tomli>=2.0; python_version < '3.11'"] # tomllib before 3.11 [project.optional-dependencies] # the system tray for qsv-clientd: `pip install 'qsv-client[tray]'` 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..b6877e0 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): diff --git a/src/qsv_client/daemon/config.py b/src/qsv_client/daemon/config.py index 0584a04..c4ce066 100644 --- a/src/qsv_client/daemon/config.py +++ b/src/qsv_client/daemon/config.py @@ -1,4 +1,4 @@ -"""Where qsv-clientd keeps its files, and its JSON configuration.""" +"""Where qsv-clientd keeps its files, and its TOML configuration.""" from __future__ import annotations @@ -9,6 +9,11 @@ from dataclasses import dataclass, field, fields from pathlib import Path +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - CI's 3.10 jobs run it, coverage merges from 3.11+ + import tomli as tomllib + DEFAULT_PORT = 14470 """Loopback port for browsers (Data Schematics). qsv pro's API uses 14462.""" @@ -21,6 +26,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 +72,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 +131,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 +163,13 @@ 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: + 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 +182,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/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..b32946c 100644 --- a/src/qsv_client/tray/controller.py +++ b/src/qsv_client/tray/controller.py @@ -21,7 +21,7 @@ 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 from ..daemon.host import DaemonHost from ..daemon.server import Daemon from ..errors import QsvDaemonError @@ -37,7 +37,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: @@ -476,15 +476,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 +493,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 +520,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..6068ca5 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,20 @@ 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_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 +164,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 -------------------------------------------------------------- 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_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..b43f5a0 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 @@ -364,11 +364,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 @@ -826,18 +826,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 +854,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 +937,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 efcbc1e..c791892 100644 --- a/uv.lock +++ b/uv.lock @@ -227,7 +227,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] 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 = [ @@ -811,6 +811,9 @@ wheels = [ name = "qsv-client" version = "0.6.0" source = { editable = "." } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] [package.optional-dependencies] tray = [ @@ -832,6 +835,7 @@ dev = [ requires-dist = [ { name = "pillow", marker = "extra == 'tray'", specifier = ">=10" }, { name = "pystray", marker = "extra == 'tray'", specifier = ">=0.19.5,<0.20" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, ] provides-extras = ["tray"] From e25dc4e81dd59c28c5b045c42779518f0bba44de Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:04:51 -0400 Subject: [PATCH 06/10] style(config): the tomli import's comment says only what it is for Co-Authored-By: Claude Opus 5.5 --- src/qsv_client/daemon/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qsv_client/daemon/config.py b/src/qsv_client/daemon/config.py index c4ce066..1166fa2 100644 --- a/src/qsv_client/daemon/config.py +++ b/src/qsv_client/daemon/config.py @@ -11,7 +11,7 @@ if sys.version_info >= (3, 11): import tomllib -else: # pragma: no cover - CI's 3.10 jobs run it, coverage merges from 3.11+ +else: # Python 3.10 import tomli as tomllib DEFAULT_PORT = 14470 From 47246b60683f8d95a5311180ae4738ef1fa8c284 Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:08:51 -0400 Subject: [PATCH 07/10] feat(config): a JSON config file says the config is TOML now Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- src/qsv_client/daemon/config.py | 5 +++++ tests/test_daemon_config_service.py | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0aaa68..6f39045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,8 @@ gets the current version. - **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. On + `daemon.json` from an earlier release is no longer read: move its settings across + (`serve --config` with an old JSON file says so). On Python 3.10 this adds `tomli`, the only runtime dependency (3.11+ uses `tomllib`). - 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. diff --git a/src/qsv_client/daemon/config.py b/src/qsv_client/daemon/config.py index 1166fa2..7e731a8 100644 --- a/src/qsv_client/daemon/config.py +++ b/src/qsv_client/daemon/config.py @@ -169,6 +169,11 @@ def load(cls, path: Path | None = None) -> DaemonConfig: 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)) diff --git a/tests/test_daemon_config_service.py b/tests/test_daemon_config_service.py index 6068ca5..d744a4c 100644 --- a/tests/test_daemon_config_service.py +++ b/tests/test_daemon_config_service.py @@ -151,6 +151,13 @@ def test_config_must_be_valid_toml(tmp_path: Path, text: str) -> None: 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.toml" path.write_text("port = 1\nprot = 2\n") From 16f241203651d5456d55897ffde4ab4808cb9fed Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:27:01 -0400 Subject: [PATCH 08/10] feat(daemon): say when a qsv older than 27.0.0 can't make live schematics serve logs a warning and the tray notifies once per qsv version, rather than schematics silently never connecting. Nothing is blocked. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ src/qsv_client/daemon/config.py | 21 +++++++++++++++++ src/qsv_client/daemon/server.py | 4 ++++ src/qsv_client/tray/controller.py | 22 +++++++++++++++++- tests/test_daemon_config_service.py | 19 ++++++++++++++++ tests/test_daemon_server_edges.py | 15 +++++++++++++ tests/test_tray_controller.py | 35 +++++++++++++++++++++++++++-- 7 files changed, 116 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f39045..81e1a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ gets the current version. 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 diff --git a/src/qsv_client/daemon/config.py b/src/qsv_client/daemon/config.py index 7e731a8..2791f89 100644 --- a/src/qsv_client/daemon/config.py +++ b/src/qsv_client/daemon/config.py @@ -9,6 +9,8 @@ from dataclasses import dataclass, field, fields from pathlib import Path +from ..capabilities import parse_version + if sys.version_info >= (3, 11): import tomllib else: # Python 3.10 @@ -19,6 +21,25 @@ 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 diff --git a/src/qsv_client/daemon/server.py b/src/qsv_client/daemon/server.py index c1d001a..b4ba8a1 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 diff --git a/src/qsv_client/tray/controller.py b/src/qsv_client/tray/controller.py index b32946c..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_path, config_template, 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 @@ -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: diff --git a/tests/test_daemon_config_service.py b/tests/test_daemon_config_service.py index d744a4c..0b19b81 100644 --- a/tests/test_daemon_config_service.py +++ b/tests/test_daemon_config_service.py @@ -416,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_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_tray_controller.py b/tests/test_tray_controller.py index b43f5a0..1f54cfd 100644 --- a/tests/test_tray_controller.py +++ b/tests/test_tray_controller.py @@ -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() @@ -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" From f99570dbd5856ce6e3cdac0f98397d506c50bc65 Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:45:51 -0400 Subject: [PATCH 09/10] feat!: require Python 3.11+ and Pillow 13 Pillow 13.0.0 fixes the 41 open Dependabot alerts on the tray extra's Pillow 12.3.0, and needs Python 3.11, so qsv-client drops 3.10. The daemon's config is read with the stdlib's tomllib, so tomli is gone and there are no runtime dependencies again. ruff's py311 rules replace asyncio.TimeoutError with its builtin alias TimeoutError. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +- .serena/memories/core.md | 2 +- .serena/memories/tech_stack.md | 4 +- CHANGELOG.md | 7 +- CLAUDE.md | 9 +- README.md | 4 +- docs/DAEMON.md | 2 +- pyproject.toml | 8 +- src/qsv_client/client.py | 2 +- src/qsv_client/daemon/client.py | 2 +- src/qsv_client/daemon/config.py | 6 +- src/qsv_client/daemon/server.py | 8 +- uv.lock | 180 +------------------------------- 13 files changed, 34 insertions(+), 206 deletions(-) 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 06fc350..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 (except `tomli` on 3.10, for daemon.toml). 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 bfbc574..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). Sole exception: `tomli` on Python 3.10, for reading daemon.toml (`tomllib` on 3.11+). +- 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 81e1a04..81c5f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,12 +38,15 @@ gets the current version. ### 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). On - Python 3.10 this adds `tomli`, the only runtime dependency (3.11+ uses `tomllib`). + (`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. diff --git a/CLAUDE.md b/CLAUDE.md index 1013d76..3b98140 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +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, except `tomli` on Python 3.10 (`tomllib` from 3.11) for the daemon's -config. Python 3.10+. +No runtime dependencies (the daemon's config is read with the stdlib's `tomllib`). Python 3.11+. ## Commands @@ -15,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 @@ -73,7 +72,7 @@ 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 (beyond `tomli` on 3.10) and 3.10-compatible +- 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 diff --git a/README.md b/README.md index 68a011a..c46b895 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ 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 (on Python 3.10, `tomli`, to read - the daemon's config). Python 3.10+ on Linux, macOS and Windows. +- **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). diff --git a/docs/DAEMON.md b/docs/DAEMON.md index 210a47f..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 diff --git a/pyproject.toml b/pyproject.toml index 179efcb..964cd0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Run the qsv CSV toolkit (25.0.0+) from Python: timeouts that kill 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 = [ @@ -23,11 +23,11 @@ classifiers = [ "Topic :: Scientific/Engineering :: Information Analysis", "Typing :: Typed", ] -dependencies = ["tomli>=2.0; python_version < '3.11'"] # tomllib before 3.11 +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/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/client.py b/src/qsv_client/daemon/client.py index b6877e0..126ad3a 100644 --- a/src/qsv_client/daemon/client.py +++ b/src/qsv_client/daemon/client.py @@ -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 2791f89..e7d758c 100644 --- a/src/qsv_client/daemon/config.py +++ b/src/qsv_client/daemon/config.py @@ -5,17 +5,13 @@ 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 -if sys.version_info >= (3, 11): - import tomllib -else: # Python 3.10 - import tomli as tomllib - DEFAULT_PORT = 14470 """Loopback port for browsers (Data Schematics). qsv pro's API uses 14462.""" diff --git a/src/qsv_client/daemon/server.py b/src/qsv_client/daemon/server.py index b4ba8a1..5f26d69 100644 --- a/src/qsv_client/daemon/server.py +++ b/src/qsv_client/daemon/server.py @@ -295,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) @@ -333,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() @@ -999,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/uv.lock b/uv.lock index c791892..2867f14 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,9 @@ version = 1 revision = 3 -requires-python = ">=3.10" +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", marker = "python_full_version < '3.11'" }, -] -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'" }, ] @@ -811,14 +646,10 @@ wheels = [ name = "qsv-client" version = "0.6.0" source = { editable = "." } -dependencies = [ - { name = "tomli", marker = "python_full_version < '3.11'" }, -] [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" }, ] @@ -833,9 +664,8 @@ 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" }, - { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, ] provides-extras = ["tray"] From 2ad8d1ac6590eb7f73c67b718be58a0162d41412 Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:57:20 -0400 Subject: [PATCH 10/10] docs(readme): qsv-clientd viz, daemon.toml, and qsv 27.0.0 for live schematics [skip ci] Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c46b895..ea7caf1 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ subprocess pitfalls: 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 @@ -135,10 +136,17 @@ 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