ilmzot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+
12
+ permissions:
13
+ id-token: write
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.12"
21
+
22
+ - name: Build
23
+ run: |
24
+ python -m pip install --upgrade build
25
+ python -m build
26
+
27
+ - name: Publish
28
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ .agent-vault/
2
+ .venv/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ state.json
@@ -0,0 +1,5 @@
1
+ # Contributing
2
+
3
+ Create a Python 3.12 environment, run `uv sync`, then run `uv run pytest`.
4
+ Keep Zotero and NotebookLM calls behind their existing module boundaries and
5
+ mock all network calls in unit tests.
ilmzot-0.1.0/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ilmzot contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and sell copies
9
+ of the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18
+
ilmzot-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: ilmzot
3
+ Version: 0.1.0
4
+ Summary: Feed your Zotero knowledge into NotebookLM.
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 ilmzot contributors
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and sell copies
13
+ of the Software, and to permit persons to whom the Software is furnished to do so,
14
+ subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
22
+
23
+ License-File: LICENSE
24
+ Requires-Python: <3.13,>=3.10
25
+ Requires-Dist: notebooklm-py[cookies]<0.9,>=0.8.1
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # ilmzot
32
+
33
+ Feed your Zotero knowledge into NotebookLM.
34
+
35
+ `ilmzot` mirrors Zotero collections into same-named NotebookLM notebooks while
36
+ keeping Zotero as the source of truth. It is one-way: local PDFs are uploaded,
37
+ but Zotero is never modified and disappeared items are not deleted remotely.
38
+
39
+ ## Quickstart
40
+
41
+ ```sh
42
+ ilmzot setup
43
+ ilmzot ls
44
+ ilmzot sync "Climate Modelling"
45
+ ilmzot status
46
+ ```
47
+
48
+ `ilmzot setup` detects Zotero at `localhost:23119`, authenticates NotebookLM
49
+ using browser cookies, and creates its local state directory. Use
50
+ `--browser firefox`, `--browser "firefox::Work"`, or `--login` for interactive
51
+ authentication. ilmzot supports major browsers through automatic cookie
52
+ detection. An interactive login fallback is available when browser-cookie
53
+ extraction is unavailable.
54
+
55
+ ## Installation
56
+
57
+ PyPI and Homebrew releases are coming soon. The intended Homebrew UX is
58
+ `brew install <user>/tap/ilmzot`; it will provide `ilmzot` directly without
59
+ shell configuration. For local development:
60
+
61
+ ```sh
62
+ git clone <repository-url>
63
+ cd ilmzot
64
+ uv sync
65
+ uv run ilmzot --help
66
+ ```
67
+
68
+ Python 3.10–3.12 is supported (browser-cookie support is most reliable on
69
+ 3.12). Runtime authentication is provided by `notebooklm-py[cookies]`.
70
+
71
+ ## Commands
72
+
73
+ `ilmzot sync --all` syncs the personal library to `Zotero Library`.
74
+ `-n/--notebook` changes the destination name and `-d/--dry-run` previews work.
75
+ `ilmzot doctor` is diagnostic-only and explains one concrete fix for failures.
76
+
77
+ ## Architecture and state
78
+
79
+ The standard library handles CLI parsing, HTTP, hashing, and state. `zotero.py`
80
+ owns the local Zotero API, `notebook.py` contains the unofficial NotebookLM
81
+ boundary, and `state.py` stores SHA-256 mappings. State is
82
+ `$XDG_DATA_HOME/ilmzot/state.json`, or `~/.local/share/ilmzot/state.json`.
83
+ It contains no cookies, tokens, passwords, or browser credentials.
84
+
85
+ ## Privacy and limitations
86
+
87
+ PDFs are read locally from Zotero and uploaded to NotebookLM. No Zotero cloud
88
+ API key is needed. Group libraries, bidirectional sync, metadata editing, and
89
+ destructive remote reconciliation are not supported in 0.1.0.
90
+
91
+ NotebookLM integration relies on an unofficial API exposed through
92
+ `notebooklm-py` and may require updates when Google changes NotebookLM.
93
+ ilmzot is not affiliated with Zotero or Google.
ilmzot-0.1.0/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # ilmzot
2
+
3
+ Feed your Zotero knowledge into NotebookLM.
4
+
5
+ `ilmzot` mirrors Zotero collections into same-named NotebookLM notebooks while
6
+ keeping Zotero as the source of truth. It is one-way: local PDFs are uploaded,
7
+ but Zotero is never modified and disappeared items are not deleted remotely.
8
+
9
+ ## Quickstart
10
+
11
+ ```sh
12
+ ilmzot setup
13
+ ilmzot ls
14
+ ilmzot sync "Climate Modelling"
15
+ ilmzot status
16
+ ```
17
+
18
+ `ilmzot setup` detects Zotero at `localhost:23119`, authenticates NotebookLM
19
+ using browser cookies, and creates its local state directory. Use
20
+ `--browser firefox`, `--browser "firefox::Work"`, or `--login` for interactive
21
+ authentication. ilmzot supports major browsers through automatic cookie
22
+ detection. An interactive login fallback is available when browser-cookie
23
+ extraction is unavailable.
24
+
25
+ ## Installation
26
+
27
+ PyPI and Homebrew releases are coming soon. The intended Homebrew UX is
28
+ `brew install <user>/tap/ilmzot`; it will provide `ilmzot` directly without
29
+ shell configuration. For local development:
30
+
31
+ ```sh
32
+ git clone <repository-url>
33
+ cd ilmzot
34
+ uv sync
35
+ uv run ilmzot --help
36
+ ```
37
+
38
+ Python 3.10–3.12 is supported (browser-cookie support is most reliable on
39
+ 3.12). Runtime authentication is provided by `notebooklm-py[cookies]`.
40
+
41
+ ## Commands
42
+
43
+ `ilmzot sync --all` syncs the personal library to `Zotero Library`.
44
+ `-n/--notebook` changes the destination name and `-d/--dry-run` previews work.
45
+ `ilmzot doctor` is diagnostic-only and explains one concrete fix for failures.
46
+
47
+ ## Architecture and state
48
+
49
+ The standard library handles CLI parsing, HTTP, hashing, and state. `zotero.py`
50
+ owns the local Zotero API, `notebook.py` contains the unofficial NotebookLM
51
+ boundary, and `state.py` stores SHA-256 mappings. State is
52
+ `$XDG_DATA_HOME/ilmzot/state.json`, or `~/.local/share/ilmzot/state.json`.
53
+ It contains no cookies, tokens, passwords, or browser credentials.
54
+
55
+ ## Privacy and limitations
56
+
57
+ PDFs are read locally from Zotero and uploaded to NotebookLM. No Zotero cloud
58
+ API key is needed. Group libraries, bidirectional sync, metadata editing, and
59
+ destructive remote reconciliation are not supported in 0.1.0.
60
+
61
+ NotebookLM integration relies on an unofficial API exposed through
62
+ `notebooklm-py` and may require updates when Google changes NotebookLM.
63
+ ilmzot is not affiliated with Zotero or Google.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ilmzot"
7
+ version = "0.1.0"
8
+ description = "Feed your Zotero knowledge into NotebookLM."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.10,<3.13"
12
+ dependencies = ["notebooklm-py[cookies]>=0.8.1,<0.9"]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8", "ruff>=0.6"]
16
+
17
+ [project.scripts]
18
+ ilmzot = "ilmzot.cli:main"
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
22
+
23
+ [tool.ruff]
24
+ line-length = 100
25
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from . import __version__, notebook, state, zotero
6
+
7
+
8
+ def parser() -> argparse.ArgumentParser:
9
+ p = argparse.ArgumentParser(
10
+ prog="ilmzot", description="Feed your Zotero knowledge into NotebookLM."
11
+ )
12
+ sub = p.add_subparsers(dest="command", required=True)
13
+ sub.add_parser("ls", aliases=["list"], help="List Zotero collections")
14
+ s = sub.add_parser("sync", help="Sync a Zotero collection")
15
+ s.add_argument("collection", nargs="?")
16
+ s.add_argument("--all", action="store_true")
17
+ s.add_argument("-n", "--notebook")
18
+ s.add_argument("-d", "--dry-run", action="store_true")
19
+ sub.add_parser("status", help="Show tracked sync status")
20
+ sub.add_parser("doctor", help="Diagnose local setup")
21
+ setup = sub.add_parser("setup", help="Authenticate NotebookLM and check Zotero")
22
+ setup.add_argument("--browser")
23
+ setup.add_argument("--login", action="store_true")
24
+ return p
25
+
26
+
27
+ def _zotero_error(exc: Exception) -> int:
28
+ print(f"Error: {exc}")
29
+ if isinstance(exc, zotero.LocalApiDisabled):
30
+ print("\nFix:\n Zotero → Settings → Advanced")
31
+ print(' Enable "Allow other applications on this computer to communicate with Zotero"')
32
+ return 1
33
+
34
+
35
+ def command_ls() -> int:
36
+ try:
37
+ items = zotero.collections()
38
+ except (zotero.ZoteroUnavailable, zotero.LocalApiDisabled) as exc:
39
+ return _zotero_error(exc)
40
+ names = zotero.display_names(items)
41
+ print("\n".join(names[c.key] for c in items))
42
+ return 0
43
+
44
+
45
+ def command_sync(args: argparse.Namespace) -> int:
46
+ if bool(args.collection) == args.all:
47
+ print("Error: provide exactly one collection or --all")
48
+ return 2
49
+ try:
50
+ collection_name = "Zotero Library" if args.all else args.collection
51
+ collection = None if args.all else zotero.find_collection(args.collection)
52
+ attachments = zotero.attachments(collection.key if collection else None)
53
+ data = state.load()
54
+ files = []
55
+ locally_skipped = 0
56
+ for attachment in attachments:
57
+ if attachment.path.exists():
58
+ digest = state.sha256_file(attachment.path)
59
+ previous = data["sources"].get(attachment.key)
60
+ if not args.dry_run and previous and previous.get("sha256") == digest:
61
+ locally_skipped += 1
62
+ else:
63
+ files.append((attachment.key, attachment.path, digest))
64
+ notebook_name = args.notebook or collection_name
65
+ added, updated, skipped, notebook_id = notebook.sync_notebook(
66
+ notebook_name, files, args.dry_run
67
+ )
68
+ skipped += locally_skipped
69
+ if not args.dry_run:
70
+ for key, path, digest in files:
71
+ data["sources"][key] = {
72
+ "notebook_id": notebook_id,
73
+ "collection": collection_name,
74
+ "path": str(path),
75
+ "sha256": digest,
76
+ "title": path.name,
77
+ }
78
+ state.save(data)
79
+ print(f"{added} added, {updated} updated, {skipped} skipped")
80
+ return 0
81
+ except (
82
+ zotero.ZoteroUnavailable,
83
+ zotero.LocalApiDisabled,
84
+ zotero.CollectionNotFound,
85
+ notebook.NotebookError,
86
+ OSError,
87
+ ) as exc:
88
+ return (
89
+ _zotero_error(exc)
90
+ if isinstance(exc, (zotero.ZoteroUnavailable, zotero.LocalApiDisabled))
91
+ else (print(f"Error: {exc}") or 1)
92
+ )
93
+
94
+
95
+ def command_setup(args: argparse.Namespace) -> int:
96
+ status = zotero.check()
97
+ if status == "unavailable":
98
+ print("Zotero is not running. Start Zotero and run setup again.")
99
+ return 1
100
+ if status == "disabled":
101
+ print("Zotero → Settings → Advanced")
102
+ print('Enable: "Allow other applications on this computer to communicate with Zotero"')
103
+ input("Press Enter after enabling it to retry... ")
104
+ if zotero.check() != "available":
105
+ print("Zotero local API is still unavailable.")
106
+ return 1
107
+ try:
108
+ notebook.authenticate(args.browser, args.login)
109
+ notebook.notebooks()
110
+ except notebook.NotebookError as exc:
111
+ print(f"NotebookLM setup failed: {exc}")
112
+ return 1
113
+ state_path = state.state_path()
114
+ state_path.parent.mkdir(parents=True, exist_ok=True)
115
+ print(f"Setup complete. Zotero API available; NotebookLM authenticated; state: {state_path}")
116
+ return 0
117
+
118
+
119
+ def command_status() -> int:
120
+ data = state.load()
121
+ groups: dict[str, list] = {}
122
+ for entry in data["sources"].values():
123
+ groups.setdefault(entry["collection"], []).append(entry)
124
+ print("Zotero scope NotebookLM Tracked Present")
125
+ print("-" * 67)
126
+ try:
127
+ available = {b.get("title", b.get("name", "")).casefold() for b in notebook.notebooks()}
128
+ except notebook.NotebookError:
129
+ available = set()
130
+ for scope, entries in sorted(groups.items()):
131
+ present = len(entries) if scope.casefold() in available else 0
132
+ destination = scope if present else f"{scope} (missing)"
133
+ print(f"{scope:<25} {destination:<25} {len(entries):>7} {present:>8}")
134
+ return 0
135
+
136
+
137
+ def command_doctor() -> int:
138
+ z = zotero.check()
139
+ print("Zotero")
140
+ print(" ✓ running" if z != "unavailable" else " ✗ not running")
141
+ print(
142
+ " ✓ local API enabled"
143
+ if z == "available"
144
+ else " ✗ local API disabled"
145
+ if z == "disabled"
146
+ else " ✗ unavailable"
147
+ )
148
+ if z == "disabled":
149
+ print(
150
+ '\nFix:\n Zotero → Settings → Advanced\n Enable "Allow other applications on this computer to communicate with Zotero"'
151
+ )
152
+ return 1
153
+ try:
154
+ cols, atts, books = zotero.collections(), zotero.attachments(), notebook.notebooks()
155
+ print(
156
+ f" ✓ {len(cols)} collections\n ✓ {len(atts)} PDF attachments\n\nNotebookLM\n ✓ authenticated\n ✓ account reachable\n ✓ {len(books)} notebooks"
157
+ )
158
+ except (
159
+ zotero.ZoteroUnavailable,
160
+ zotero.LocalApiDisabled,
161
+ notebook.NotebookError,
162
+ OSError,
163
+ ) as exc:
164
+ print(f"\nNotebookLM\n ✗ {exc}\n\nFix: run ilmzot setup")
165
+ return 1
166
+ path = state.state_path()
167
+ try:
168
+ path.parent.mkdir(parents=True, exist_ok=True)
169
+ print(f"\nilmzot\n ✓ state directory writable\n ✓ version {__version__}\n\nReady.")
170
+ except OSError as exc:
171
+ print(f"\nilmzot\n ✗ state directory unavailable: {exc}")
172
+ return 1
173
+ return 0
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ args = parser().parse_args(argv)
178
+ return (
179
+ {
180
+ "ls": command_ls,
181
+ "list": command_ls,
182
+ "sync": command_sync,
183
+ "setup": command_setup,
184
+ "status": command_status,
185
+ "doctor": command_doctor,
186
+ }[args.command](args)
187
+ if args.command == "sync" or args.command == "setup"
188
+ else {
189
+ "ls": command_ls,
190
+ "list": command_ls,
191
+ "status": command_status,
192
+ "doctor": command_doctor,
193
+ }[args.command]()
194
+ )
195
+
196
+
197
+ if __name__ == "__main__":
198
+ raise SystemExit(main())
@@ -0,0 +1,111 @@
1
+ """NotebookLM boundary. The unofficial dependency is intentionally isolated here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ class NotebookError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def _client() -> Any:
18
+ try:
19
+ module = importlib.import_module("notebooklm")
20
+ factory = getattr(module, "NotebookLMClient", None)
21
+ if factory:
22
+ return factory.from_storage()
23
+ return module
24
+ except ImportError as exc:
25
+ raise NotebookError("Install ilmzot's dependencies before using NotebookLM") from exc
26
+
27
+
28
+ def authenticate(browser: str | None = None, login: bool = False) -> None:
29
+ if login:
30
+ subprocess.run([sys.executable, "-m", "notebooklm", "login"], check=True)
31
+ return
32
+ # notebooklm-py owns cookie extraction and its browser/profile discovery.
33
+ selector = browser or "auto"
34
+ subprocess.run(
35
+ [sys.executable, "-m", "notebooklm", "login", "--browser-cookies", selector],
36
+ check=True,
37
+ )
38
+
39
+
40
+ async def _notebooks_async() -> list[Any]:
41
+ context = _client()
42
+ async with context as client:
43
+ return await client.notebooks.list()
44
+
45
+
46
+ def _as_dict(item: Any) -> dict[str, Any]:
47
+ return item if isinstance(item, dict) else {"id": item.id, "title": item.title}
48
+
49
+
50
+ def notebooks() -> list[dict[str, Any]]:
51
+ return [_as_dict(item) for item in asyncio.run(_notebooks_async())]
52
+
53
+
54
+ def sync_notebook(
55
+ name: str, files: list[tuple[str, Path, str]], dry_run: bool = False
56
+ ) -> tuple[int, int, int, str]:
57
+ books = notebooks()
58
+ matches = [b for b in books if b.get("title", b.get("name", "")).casefold() == name.casefold()]
59
+ if len(matches) > 1:
60
+ raise NotebookError(f'Multiple NotebookLM notebooks named "{name}"')
61
+ book = matches[0] if matches else None
62
+ if not book:
63
+ if dry_run:
64
+ return (len(files), 0, 0, "")
65
+
66
+ async def create_book():
67
+ async with _client() as client:
68
+ return await client.notebooks.create(name)
69
+
70
+ book = _as_dict(asyncio.run(create_book()))
71
+ notebook_id = str(book.get("id", book.get("notebook_id", "")))
72
+ if dry_run:
73
+ return (len(files), 0, 0, notebook_id)
74
+
75
+ async def list_sources():
76
+ async with _client() as client:
77
+ return await client.sources.list(notebook_id)
78
+
79
+ listed = [_as_dict(s) for s in asyncio.run(list_sources())]
80
+ added = updated = skipped = 0
81
+ for key, path, digest in files:
82
+ source = next(
83
+ (
84
+ s
85
+ for s in listed
86
+ if s.get("title") == path.name or s.get("metadata", {}).get("attachment_key") == key
87
+ ),
88
+ None,
89
+ )
90
+ if source and source.get("metadata", {}).get("sha256") == digest:
91
+ skipped += 1
92
+ continue
93
+ if source:
94
+
95
+ async def delete_source(source_id: str):
96
+ async with _client() as client:
97
+ await client.sources.delete(notebook_id, source_id)
98
+
99
+ asyncio.run(delete_source(str(source["id"])))
100
+ updated += 1
101
+ else:
102
+ added += 1
103
+
104
+ async def upload_source(upload_path: Path):
105
+ async with _client() as client:
106
+ await client.sources.add_file(
107
+ notebook_id, upload_path, wait=True, title=upload_path.name
108
+ )
109
+
110
+ asyncio.run(upload_source(path))
111
+ return added, updated, skipped, notebook_id
@@ -0,0 +1,39 @@
1
+ """Persistent, non-secret sync state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def state_path() -> Path:
13
+ root = os.environ.get("XDG_DATA_HOME")
14
+ return (Path(root) if root else Path.home() / ".local" / "share") / "ilmzot" / "state.json"
15
+
16
+
17
+ def load() -> dict[str, Any]:
18
+ path = state_path()
19
+ if not path.exists():
20
+ return {"sources": {}}
21
+ data = json.loads(path.read_text())
22
+ data.setdefault("sources", {})
23
+ return data
24
+
25
+
26
+ def save(data: dict[str, Any]) -> None:
27
+ path = state_path()
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ temporary = path.with_suffix(".tmp")
30
+ temporary.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
31
+ temporary.replace(path)
32
+
33
+
34
+ def sha256_file(path: Path) -> str:
35
+ digest = hashlib.sha256()
36
+ with path.open("rb") as handle:
37
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
38
+ digest.update(chunk)
39
+ return digest.hexdigest()
@@ -0,0 +1,129 @@
1
+ """Small client for Zotero's documented local HTTP API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.parse import unquote, urlparse
10
+ from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
11
+
12
+ BASE = "http://localhost:23119/api"
13
+
14
+
15
+ class ZoteroUnavailable(RuntimeError):
16
+ pass
17
+
18
+
19
+ class LocalApiDisabled(RuntimeError):
20
+ pass
21
+
22
+
23
+ class CollectionNotFound(RuntimeError):
24
+ pass
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Collection:
29
+ key: str
30
+ name: str
31
+ parent: str | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Attachment:
36
+ key: str
37
+ title: str
38
+ path: Path
39
+
40
+
41
+ def _get(path: str) -> object:
42
+ try:
43
+ with urlopen(
44
+ Request(BASE + path, headers={"Accept": "application/json"}), timeout=3
45
+ ) as response:
46
+ return json.load(response)
47
+ except HTTPError as exc:
48
+ if exc.code in (403, 404):
49
+ raise LocalApiDisabled("Zotero local API access is disabled") from exc
50
+ raise ZoteroUnavailable(f"Zotero returned HTTP {exc.code}") from exc
51
+ except (URLError, TimeoutError, OSError) as exc:
52
+ raise ZoteroUnavailable("Zotero is not running") from exc
53
+
54
+
55
+ def check() -> str:
56
+ try:
57
+ _get("/users/0/items?limit=1")
58
+ except LocalApiDisabled:
59
+ return "disabled"
60
+ except ZoteroUnavailable:
61
+ return "unavailable"
62
+ return "available"
63
+
64
+
65
+ def collections() -> list[Collection]:
66
+ raw = _get("/users/0/collections") # type: ignore
67
+ return [
68
+ Collection(str(x["key"]), x["data"]["name"], x["data"].get("parentCollection")) for x in raw
69
+ ] # type: ignore
70
+
71
+
72
+ def _local_file_path(key: str) -> Path | None:
73
+ class NoRedirect(HTTPRedirectHandler):
74
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
75
+ return None
76
+
77
+ try:
78
+ response = build_opener(NoRedirect).open(
79
+ Request(f"{BASE}/users/0/items/{key}/file"), timeout=3
80
+ )
81
+ except HTTPError as exc:
82
+ if exc.code in (301, 302, 303, 307, 308):
83
+ location = exc.headers.get("Location", "")
84
+ parsed = urlparse(location)
85
+ return Path(unquote(parsed.path)) if parsed.scheme == "file" else None
86
+ return None
87
+ else:
88
+ response.close()
89
+ return None
90
+
91
+
92
+ def display_names(items: list[Collection]) -> dict[str, str]:
93
+ by_key = {item.key: item for item in items}
94
+
95
+ def path(item: Collection) -> str:
96
+ parents, current = [], item
97
+ while current:
98
+ parents.append(current.name)
99
+ current = by_key.get(current.parent) if current.parent else None
100
+ return " / ".join(reversed(parents))
101
+
102
+ return {item.key: path(item) for item in items}
103
+
104
+
105
+ def find_collection(name: str) -> Collection:
106
+ matches = [c for c in collections() if c.name.casefold() == name.casefold()]
107
+ if not matches:
108
+ raise CollectionNotFound(f'Collection not found: "{name}"')
109
+ if len(matches) > 1:
110
+ raise CollectionNotFound(f'Multiple collections named "{name}"; use a unique name')
111
+ return matches[0]
112
+
113
+
114
+ def attachments(collection_key: str | None = None) -> list[Attachment]:
115
+ suffix = f"/users/0/collections/{collection_key}/items" if collection_key else "/users/0/items"
116
+ raw = _get(suffix + "?limit=10000") # type: ignore
117
+ result = []
118
+ for item in raw: # type: ignore
119
+ data = item.get("data", {})
120
+ if data.get("itemType") == "attachment" and data.get("contentType") == "application/pdf":
121
+ path = data.get("path", "")
122
+ local_path = (
123
+ Path(path)
124
+ if path and not path.startswith("storage:")
125
+ else _local_file_path(item["key"])
126
+ )
127
+ if local_path:
128
+ result.append(Attachment(item["key"], data.get("title", "Untitled"), local_path))
129
+ return result
@@ -0,0 +1,18 @@
1
+ from ilmzot.cli import parser
2
+
3
+
4
+ def test_positional_collection_and_options():
5
+ args = parser().parse_args(["sync", "Climate Modelling", "-n", "Research", "-d"])
6
+ assert args.collection == "Climate Modelling"
7
+ assert args.notebook == "Research" and args.dry_run
8
+
9
+
10
+ def test_all():
11
+ assert parser().parse_args(["sync", "--all"]).all
12
+
13
+
14
+ def test_sync_requires_exactly_one_scope(capsys, monkeypatch):
15
+ from ilmzot import cli
16
+
17
+ assert cli.main(["sync"]) == 2
18
+ assert cli.main(["sync", "x", "--all"]) == 2
@@ -0,0 +1,12 @@
1
+ import pytest
2
+
3
+ from ilmzot import notebook
4
+
5
+
6
+ def test_duplicate_notebooks(monkeypatch, tmp_path):
7
+ monkeypatch.setattr(
8
+ notebook, "notebooks", lambda: [{"id": "1", "title": "X"}, {"id": "2", "title": "x"}]
9
+ )
10
+ monkeypatch.setattr(notebook, "_client", lambda: object())
11
+ with pytest.raises(notebook.NotebookError):
12
+ notebook.sync_notebook("X", [], dry_run=True)
@@ -0,0 +1,17 @@
1
+ import hashlib
2
+
3
+ from ilmzot import state
4
+
5
+
6
+ def test_state_path_xdg(monkeypatch, tmp_path):
7
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
8
+ assert state.state_path() == tmp_path / "ilmzot" / "state.json"
9
+
10
+
11
+ def test_state_round_trip_and_hash(monkeypatch, tmp_path):
12
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
13
+ path = tmp_path / "a.pdf"
14
+ path.write_bytes(b"pdf")
15
+ assert state.sha256_file(path) == hashlib.sha256(b"pdf").hexdigest()
16
+ state.save({"sources": {"a": {"sha256": state.sha256_file(path)}}})
17
+ assert state.load()["sources"]["a"]["sha256"]
@@ -0,0 +1,39 @@
1
+ import pytest
2
+
3
+ from ilmzot import zotero
4
+
5
+
6
+ def test_nested_display_names():
7
+ items = [zotero.Collection("a", "Energy"), zotero.Collection("b", "Electricity Markets", "a")]
8
+ assert zotero.display_names(items)["b"] == "Energy / Electricity Markets"
9
+
10
+
11
+ def test_find_collection_case_insensitive(monkeypatch):
12
+ monkeypatch.setattr(
13
+ zotero, "collections", lambda: [zotero.Collection("a", "Climate Modelling")]
14
+ )
15
+ assert zotero.find_collection("climate modelling").key == "a"
16
+
17
+
18
+ def test_collection_not_found(monkeypatch):
19
+ monkeypatch.setattr(zotero, "collections", list)
20
+ with pytest.raises(zotero.CollectionNotFound):
21
+ zotero.find_collection("missing")
22
+
23
+
24
+ def test_duplicate_collection_names(monkeypatch):
25
+ monkeypatch.setattr(
26
+ zotero,
27
+ "collections",
28
+ lambda: [zotero.Collection("a", "Energy"), zotero.Collection("b", "energy")],
29
+ )
30
+ with pytest.raises(zotero.CollectionNotFound):
31
+ zotero.find_collection("Energy")
32
+
33
+
34
+ def test_unavailable(monkeypatch):
35
+ def fail(*args, **kwargs):
36
+ raise zotero.URLError("offline")
37
+
38
+ monkeypatch.setattr(zotero, "urlopen", fail)
39
+ assert zotero.check() == "unavailable"
ilmzot-0.1.0/uv.lock ADDED
@@ -0,0 +1,343 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10, <3.13"
4
+
5
+ [[package]]
6
+ name = "anyio"
7
+ version = "4.14.2"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ dependencies = [
10
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
11
+ { name = "idna" },
12
+ { name = "typing-extensions" },
13
+ ]
14
+ sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
15
+ wheels = [
16
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
17
+ ]
18
+
19
+ [[package]]
20
+ name = "certifi"
21
+ version = "2026.7.22"
22
+ source = { registry = "https://pypi.org/simple" }
23
+ sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
24
+ wheels = [
25
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
26
+ ]
27
+
28
+ [[package]]
29
+ name = "click"
30
+ version = "8.5.0"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
33
+ wheels = [
34
+ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
35
+ ]
36
+
37
+ [[package]]
38
+ name = "colorama"
39
+ version = "0.4.6"
40
+ source = { registry = "https://pypi.org/simple" }
41
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
42
+ wheels = [
43
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
44
+ ]
45
+
46
+ [[package]]
47
+ name = "exceptiongroup"
48
+ version = "1.3.1"
49
+ source = { registry = "https://pypi.org/simple" }
50
+ dependencies = [
51
+ { name = "typing-extensions" },
52
+ ]
53
+ 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" }
54
+ wheels = [
55
+ { 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" },
56
+ ]
57
+
58
+ [[package]]
59
+ name = "filelock"
60
+ version = "3.32.5"
61
+ source = { registry = "https://pypi.org/simple" }
62
+ sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" }
63
+ wheels = [
64
+ { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" },
65
+ ]
66
+
67
+ [[package]]
68
+ name = "h11"
69
+ version = "0.16.0"
70
+ source = { registry = "https://pypi.org/simple" }
71
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
72
+ wheels = [
73
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
74
+ ]
75
+
76
+ [[package]]
77
+ name = "httpcore"
78
+ version = "1.0.9"
79
+ source = { registry = "https://pypi.org/simple" }
80
+ dependencies = [
81
+ { name = "certifi" },
82
+ { name = "h11" },
83
+ ]
84
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
85
+ wheels = [
86
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
87
+ ]
88
+
89
+ [[package]]
90
+ name = "httpx"
91
+ version = "0.28.1"
92
+ source = { registry = "https://pypi.org/simple" }
93
+ dependencies = [
94
+ { name = "anyio" },
95
+ { name = "certifi" },
96
+ { name = "httpcore" },
97
+ { name = "idna" },
98
+ ]
99
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
100
+ wheels = [
101
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
102
+ ]
103
+
104
+ [[package]]
105
+ name = "idna"
106
+ version = "3.19"
107
+ source = { registry = "https://pypi.org/simple" }
108
+ sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
109
+ wheels = [
110
+ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
111
+ ]
112
+
113
+ [[package]]
114
+ name = "ilmzot"
115
+ version = "0.1.0"
116
+ source = { editable = "." }
117
+ dependencies = [
118
+ { name = "notebooklm-py", extra = ["cookies"] },
119
+ ]
120
+
121
+ [package.optional-dependencies]
122
+ dev = [
123
+ { name = "pytest" },
124
+ { name = "ruff" },
125
+ ]
126
+
127
+ [package.metadata]
128
+ requires-dist = [
129
+ { name = "notebooklm-py", extras = ["cookies"], specifier = ">=0.8.1,<0.9" },
130
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
131
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
132
+ ]
133
+ provides-extras = ["dev"]
134
+
135
+ [[package]]
136
+ name = "iniconfig"
137
+ version = "2.3.0"
138
+ source = { registry = "https://pypi.org/simple" }
139
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
140
+ wheels = [
141
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
142
+ ]
143
+
144
+ [[package]]
145
+ name = "markdown-it-py"
146
+ version = "4.2.0"
147
+ source = { registry = "https://pypi.org/simple" }
148
+ dependencies = [
149
+ { name = "mdurl" },
150
+ ]
151
+ sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
152
+ wheels = [
153
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
154
+ ]
155
+
156
+ [[package]]
157
+ name = "mdurl"
158
+ version = "0.1.2"
159
+ source = { registry = "https://pypi.org/simple" }
160
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
161
+ wheels = [
162
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
163
+ ]
164
+
165
+ [[package]]
166
+ name = "notebooklm-py"
167
+ version = "0.8.1"
168
+ source = { registry = "https://pypi.org/simple" }
169
+ dependencies = [
170
+ { name = "click" },
171
+ { name = "filelock" },
172
+ { name = "httpx" },
173
+ { name = "rich" },
174
+ ]
175
+ sdist = { url = "https://files.pythonhosted.org/packages/5c/ff/5c352c1bf66a0f5ae59157ceb544991d5733ef379d4e17bc70253c22fda4/notebooklm_py-0.8.1.tar.gz", hash = "sha256:43ba51a9609a9530bfcd9ad06c1035edeac4b0e40156a5a7ee7ee302621666cd", size = 16334264, upload-time = "2026-08-14T15:00:29.158Z" }
176
+ wheels = [
177
+ { url = "https://files.pythonhosted.org/packages/15/82/bca1ba131ba994675dfd44cf7f731f9725e0265b056ad8b6bdc0467c9201/notebooklm_py-0.8.1-py3-none-any.whl", hash = "sha256:4d7bc3e16bedde8296a85aa0376a69e49c744d82ffa9836447c584353e2001d8", size = 1617300, upload-time = "2026-08-14T15:00:26.95Z" },
178
+ ]
179
+
180
+ [package.optional-dependencies]
181
+ cookies = [
182
+ { name = "rookiepy" },
183
+ ]
184
+
185
+ [[package]]
186
+ name = "packaging"
187
+ version = "26.3"
188
+ source = { registry = "https://pypi.org/simple" }
189
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
190
+ wheels = [
191
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
192
+ ]
193
+
194
+ [[package]]
195
+ name = "pluggy"
196
+ version = "1.6.0"
197
+ source = { registry = "https://pypi.org/simple" }
198
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
199
+ wheels = [
200
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
201
+ ]
202
+
203
+ [[package]]
204
+ name = "pygments"
205
+ version = "2.21.0"
206
+ source = { registry = "https://pypi.org/simple" }
207
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
208
+ wheels = [
209
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
210
+ ]
211
+
212
+ [[package]]
213
+ name = "pytest"
214
+ version = "9.1.1"
215
+ source = { registry = "https://pypi.org/simple" }
216
+ dependencies = [
217
+ { name = "colorama", marker = "sys_platform == 'win32'" },
218
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
219
+ { name = "iniconfig" },
220
+ { name = "packaging" },
221
+ { name = "pluggy" },
222
+ { name = "pygments" },
223
+ { name = "tomli", marker = "python_full_version < '3.11'" },
224
+ ]
225
+ 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" }
226
+ wheels = [
227
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
228
+ ]
229
+
230
+ [[package]]
231
+ name = "rich"
232
+ version = "15.0.0"
233
+ source = { registry = "https://pypi.org/simple" }
234
+ dependencies = [
235
+ { name = "markdown-it-py" },
236
+ { name = "pygments" },
237
+ ]
238
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
239
+ wheels = [
240
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
241
+ ]
242
+
243
+ [[package]]
244
+ name = "rookiepy"
245
+ version = "0.5.6"
246
+ source = { registry = "https://pypi.org/simple" }
247
+ sdist = { url = "https://files.pythonhosted.org/packages/52/b7/b75e3bb8cdd0210a0ffb106002b678170c9a3a50ae1dc1c9bc1f701b4452/rookiepy-0.5.6.tar.gz", hash = "sha256:efa6a93b119478a96b3d8c4454215c4f1af316a24b3b3b3015b73c1c1d887078", size = 45225, upload-time = "2024-11-01T19:28:37.537Z" }
248
+ wheels = [
249
+ { url = "https://files.pythonhosted.org/packages/0f/64/6d8c3a530da92abed05cf12ec80a9765f6fbb5657013624b16a0f7a3a7d7/rookiepy-0.5.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b138d59447317438564475b3bfa80ea6adbbdc65edfa44f14c1565b90ce64257", size = 1403633, upload-time = "2024-11-01T19:25:42.113Z" },
250
+ { url = "https://files.pythonhosted.org/packages/66/77/58d20e6e45b38f9950486ca6bd8629eb0385ec9f68aadbee67db03e4fd48/rookiepy-0.5.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2c39cb06b8b2edb186d7b0afe08dbb3257bba94f2b18e3bb3ae8a80b77e01b", size = 1312421, upload-time = "2024-11-01T19:25:45.329Z" },
251
+ { url = "https://files.pythonhosted.org/packages/72/4b/d3ef49aefb2e73ed86d64ffdc90e369eae119321715c259ccbe8dc050a5f/rookiepy-0.5.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b547cd5ebf875635bd96d8c3365be3f2c664bbab45fda36c8c5b880a4d17512", size = 2678357, upload-time = "2024-11-01T19:25:47.681Z" },
252
+ { url = "https://files.pythonhosted.org/packages/bb/34/770004fc5b6c77d2d3708f4c3938e9939b0248d06f8dfeee124e128f99e3/rookiepy-0.5.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:65e6e6939d7d87942795d8275d56a17a7c046732420aa10c05e2b63ee6345e93", size = 2583944, upload-time = "2024-11-01T19:25:50.241Z" },
253
+ { url = "https://files.pythonhosted.org/packages/7e/d0/493838aae78dfeca1b3246515cb67b53f55997026b4372bf80ea81bb9206/rookiepy-0.5.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c370e4eb33f8848e686b4a14183078411fccb882fc9040823e30a8597ee00b8a", size = 2992938, upload-time = "2024-11-01T19:25:52.56Z" },
254
+ { url = "https://files.pythonhosted.org/packages/75/19/412239d02d4f73baea9e9f7f2b21ebd187c3bf38ef4ec517bb2a839e0183/rookiepy-0.5.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2e2be75c052375b9f489fefbf359935f1882fd7f80b724a976d2dd1ec462d9d", size = 2870215, upload-time = "2024-11-01T19:25:55.182Z" },
255
+ { url = "https://files.pythonhosted.org/packages/69/85/b46d140837570114a7cdb57626775ea5b456beb4ce30dc9972a616cdd709/rookiepy-0.5.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c73b5de7db85fc5f398d1f50bc6b1e3672ddeb76fd2c9721980eaeb530e69d13", size = 2979196, upload-time = "2024-11-01T19:25:57.615Z" },
256
+ { url = "https://files.pythonhosted.org/packages/2a/39/7594b696befce2aa5e50dd6f7a632b50ea94d9ec14651f1b4651f1ed24e1/rookiepy-0.5.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67391fca6afc3251c8bbd4a395f287ebfcb35652cf9a4aadf5b4df73481fac08", size = 2800928, upload-time = "2024-11-01T19:25:59.902Z" },
257
+ { url = "https://files.pythonhosted.org/packages/c5/b9/65d5ae2ee2b86a3992b9d10eeecc822c590e311fff4f25eed5eeb05b4cfc/rookiepy-0.5.6-cp310-none-win_amd64.whl", hash = "sha256:9ea86008705ff0dbbb1f74cf4847c71a6c6a848427797e0086401d9ef0117432", size = 2440815, upload-time = "2024-11-01T19:26:02.336Z" },
258
+ { url = "https://files.pythonhosted.org/packages/65/26/26bc3e2f519f14fd95f1cfeeef533c5c3f2bc356e01e8599e2cbfcba926f/rookiepy-0.5.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:265bb767edd2e4e47d3de534871f94181572236d2ae3925597f4ceba557a1525", size = 1403708, upload-time = "2024-11-01T19:26:04.704Z" },
259
+ { url = "https://files.pythonhosted.org/packages/cc/73/234f2c42b4f73b96e4b5722afae664c0870e69f14dd1408ca06a5015b3a2/rookiepy-0.5.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89253f803fcd4fbfa9f06961af9bc59efeade69b377d0d7569f5591e2e9a1b0e", size = 1312473, upload-time = "2024-11-01T19:26:07.5Z" },
260
+ { url = "https://files.pythonhosted.org/packages/1c/1c/30ed2342b68c3e1db1d362e2e05577318c2fdc580a41026ddf8d504cbb7f/rookiepy-0.5.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d2dc1770c2d77f84961ea01dce6fab32f771fb84066c94f372a69d26f61981a", size = 2678396, upload-time = "2024-11-01T19:26:11.229Z" },
261
+ { url = "https://files.pythonhosted.org/packages/21/7a/5dd1f2b95180bd45b7c49eb15d189d16445255dbc192b4565c56bcd8b13d/rookiepy-0.5.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8e69d53476ac66171823935f4fc93adf30556be37f77b775931f7c5a6d841260", size = 2583956, upload-time = "2024-11-01T19:26:13.962Z" },
262
+ { url = "https://files.pythonhosted.org/packages/87/56/d5f40ec7b29949db27c5d7da3c2f9f8d3b9fe5cb19341a899f0fa3c065b2/rookiepy-0.5.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bd868521032ae24c44946906c88518fc9bb941befe8f6d7e1d8ff355f67b598", size = 2992867, upload-time = "2024-11-01T19:26:16.801Z" },
263
+ { url = "https://files.pythonhosted.org/packages/e8/83/b382531f0e8cb3d827158f788a73b014050f5b0fd0f725a4f1994ec08311/rookiepy-0.5.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f29a324c8b44a8b7bbef632001c76b1764d647164aaaf165b5496310f57328cc", size = 2870293, upload-time = "2024-11-01T19:26:19.499Z" },
264
+ { url = "https://files.pythonhosted.org/packages/a5/49/43915b3e2b21b1455b6edacc71f27056c87b311325268465bf45f56393ca/rookiepy-0.5.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd95eb157acee95e3079f86a26cadf720f1d7ef4900735c8b5ff3a12c29f7880", size = 2979356, upload-time = "2024-11-01T19:26:21.891Z" },
265
+ { url = "https://files.pythonhosted.org/packages/00/92/e58af41f1847f746fbcda17dec454e1f249f931ca38ecb426ef93f315c3d/rookiepy-0.5.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afb0fa7a973d5cf7833ddd46854d823dad70027457fff44f3161d0128d884e17", size = 2800937, upload-time = "2024-11-01T19:26:24.051Z" },
266
+ { url = "https://files.pythonhosted.org/packages/6d/2e/5def20a848e855c1c10bf86a3e2db7008d242b4e117846a635ba6e006d75/rookiepy-0.5.6-cp311-none-win_amd64.whl", hash = "sha256:d1ba846c1d066530a32a77fc48b8e4197795151ab8940dea86f319849b544d5b", size = 2440816, upload-time = "2024-11-01T19:26:26.716Z" },
267
+ { url = "https://files.pythonhosted.org/packages/de/f3/70737571ed2fb7cd9e360b0ec6ce637c8894d712632f7e13b442f5286eef/rookiepy-0.5.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:96737f44eab781d5a010b71f7cd70ef43c6a6977dc1c54d019158b8fcfe57167", size = 1404166, upload-time = "2024-11-01T19:26:29.356Z" },
268
+ { url = "https://files.pythonhosted.org/packages/05/ae/9b69ca2821149cdb5061d6d3098a003affd7ca71b79166e98aed5d7beac8/rookiepy-0.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37a8fc437f49672387d4a512df5522a724c4440442e64cad84680f1718f68c50", size = 1311996, upload-time = "2024-11-01T19:26:31.659Z" },
269
+ { url = "https://files.pythonhosted.org/packages/d7/29/3a7a44027d542c889f9605843d5c7f8df105eb0b79ad45750d7c0b18875b/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22d0ece1ef732045f0482f72bc199cad81878154ceb62780c1e2f297c62df7e2", size = 2678772, upload-time = "2024-11-01T19:26:33.995Z" },
270
+ { url = "https://files.pythonhosted.org/packages/6b/62/fb0a8267604f11e7df565509834279b179eadb785e4d5e0b99cc096a27f4/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:307943d5780492c533bfeb6bc89d1a4b3e5b91b25af6fc1de280acc1e2ff16f6", size = 2583775, upload-time = "2024-11-01T19:26:36.665Z" },
271
+ { url = "https://files.pythonhosted.org/packages/5b/0b/c1f85445a00107f97f488f5f14ac25f1994c8436c543263e43865380e553/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c607eef14f64ba284f3754449b6eb079aa8aaa66b443d0cdd2ec9aaa1814a8da", size = 2993272, upload-time = "2024-11-01T19:26:39.668Z" },
272
+ { url = "https://files.pythonhosted.org/packages/4c/b4/7ca3ab1860f38e1d79df051aad6e8826732f3dee77fd694f226e20e8389c/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:19f4b95436705b38e4a55cfa9f3d999bb4edc6a7f91ba13b1b90bb14b1796fdb", size = 2871214, upload-time = "2024-11-01T19:26:42.24Z" },
273
+ { url = "https://files.pythonhosted.org/packages/8a/36/f45c3a833595285d790d91de20e64dff5ae1780a407033a018f0efccf852/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95b881f2f3d93bb56808eaaf8ac9e0e5e3245dda837edab194834a3caaadcbe7", size = 2971688, upload-time = "2024-11-01T19:26:44.341Z" },
274
+ { url = "https://files.pythonhosted.org/packages/19/c4/59b5e0b6efcd919683578605bfac4bc112ac34de5a871094fe3efa58896b/rookiepy-0.5.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c49a0b59bc0329928a369a479f9b1c16cbe3a2548dd8273b13515c7ced73449", size = 2801469, upload-time = "2024-11-01T19:26:47.997Z" },
275
+ { url = "https://files.pythonhosted.org/packages/e4/38/bba16936ece960db7ed10f8ebf05dc6228959511d3837020da1e9b7dcdf6/rookiepy-0.5.6-cp312-none-win_amd64.whl", hash = "sha256:6bb956c9f2dece5101aafc7a715bc672c5b30ddcaa55c9926d12440635d89cfc", size = 2441230, upload-time = "2024-11-01T19:26:50.486Z" },
276
+ { url = "https://files.pythonhosted.org/packages/a4/e1/2ea594498b77ba420213f67f096c8876523c352aaa23899279f290966418/rookiepy-0.5.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5bc244f1f170be075a9c52c8f5581ea75b93b449154633194185ffb059e1399", size = 2678887, upload-time = "2024-11-01T19:27:50.796Z" },
277
+ { url = "https://files.pythonhosted.org/packages/fd/fa/0a2741fadd2585344ecf9a0045a864997dc4f463f83592e01ffdab9146bb/rookiepy-0.5.6-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e934109fed936d35f0457016f180ef60e9cb65fd2c0cb5af4889ceb287701e63", size = 2583523, upload-time = "2024-11-01T19:27:53.351Z" },
278
+ { url = "https://files.pythonhosted.org/packages/45/65/e9338bc9a17eff505d7854d38ce05e20e5d372a3292cb6e5cd9b6eb118e1/rookiepy-0.5.6-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9853429d6c1d05b3224fe6bae0790122998819e46e607b5e8b6899a8a72442eb", size = 2993909, upload-time = "2024-11-01T19:27:56.444Z" },
279
+ { url = "https://files.pythonhosted.org/packages/48/fa/150ba5a15e90ad6391d283a7923fee038ab350abfdeff52c1fd7280889df/rookiepy-0.5.6-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac5dbd9d2e292760a987a2de148e9553e0c1799caa66751bb103be4698021bd1", size = 2870849, upload-time = "2024-11-01T19:27:59.019Z" },
280
+ { url = "https://files.pythonhosted.org/packages/7a/a4/b1d54a67105c04630d6748eccd2665fe8964dddf18ecd52970cf5aea8466/rookiepy-0.5.6-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d83261b8843482dd229eba19423a6388bdd0c662b9fc6660f9258223d3e33003", size = 2979858, upload-time = "2024-11-01T19:28:01.484Z" },
281
+ { url = "https://files.pythonhosted.org/packages/ad/20/4d5199f0e4e250730f09c5aad9b278a653415c7baf084a9cd1306e8db09e/rookiepy-0.5.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3daa59bbd9bff7c653ec5c8b13fdc6e7f20ca9b47c73ff8f8f0eee9b2549b067", size = 2801757, upload-time = "2024-11-01T19:28:03.902Z" },
282
+ ]
283
+
284
+ [[package]]
285
+ name = "ruff"
286
+ version = "0.16.5"
287
+ source = { registry = "https://pypi.org/simple" }
288
+ sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" }
289
+ wheels = [
290
+ { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" },
291
+ { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" },
292
+ { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" },
293
+ { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" },
294
+ { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" },
295
+ { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" },
296
+ { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" },
297
+ { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" },
298
+ { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" },
299
+ { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" },
300
+ { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" },
301
+ { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" },
302
+ { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" },
303
+ { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" },
304
+ { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" },
305
+ { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" },
306
+ { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" },
307
+ ]
308
+
309
+ [[package]]
310
+ name = "tomli"
311
+ version = "2.4.1"
312
+ source = { registry = "https://pypi.org/simple" }
313
+ sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
314
+ wheels = [
315
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
316
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
317
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
318
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
319
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
320
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
321
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
322
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
323
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
324
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
325
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
326
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
327
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
328
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
329
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
330
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
331
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
332
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
333
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
334
+ ]
335
+
336
+ [[package]]
337
+ name = "typing-extensions"
338
+ version = "4.16.0"
339
+ source = { registry = "https://pypi.org/simple" }
340
+ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
341
+ wheels = [
342
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
343
+ ]