fluxup 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,69 @@
1
+ name: Build and publish fluxup
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ publish_to_pypi:
7
+ description: Publish to PyPI after building
8
+ required: true
9
+ default: "false"
10
+ type: choice
11
+ options:
12
+ - "false"
13
+ - "true"
14
+ push:
15
+ tags:
16
+ - "v*"
17
+
18
+ jobs:
19
+ build:
20
+ name: Build fluxup package
21
+ runs-on: ubuntu-latest
22
+ permissions:
23
+ contents: read
24
+
25
+ steps:
26
+ - name: Check out Fluxup
27
+ uses: actions/checkout@v4
28
+
29
+ - name: Install uv
30
+ uses: astral-sh/setup-uv@v5
31
+ with:
32
+ enable-cache: true
33
+
34
+ - name: Test fluxup
35
+ shell: bash
36
+ run: |
37
+ set -euo pipefail
38
+ uv run ruff check src tests
39
+ uv run pytest -q
40
+
41
+ - name: Build fluxup distribution
42
+ shell: bash
43
+ run: uv build
44
+
45
+ - name: Upload fluxup dist
46
+ uses: actions/upload-artifact@v4
47
+ with:
48
+ name: fluxup-dist
49
+ if-no-files-found: error
50
+ path: dist/
51
+
52
+ publish:
53
+ name: Publish fluxup to PyPI
54
+ needs: build
55
+ if: github.event_name == 'push' || github.event.inputs.publish_to_pypi == 'true'
56
+ runs-on: ubuntu-latest
57
+ environment: pypi
58
+ permissions:
59
+ id-token: write
60
+
61
+ steps:
62
+ - name: Download fluxup dist
63
+ uses: actions/download-artifact@v4
64
+ with:
65
+ name: fluxup-dist
66
+ path: dist
67
+
68
+ - name: Publish to PyPI
69
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ dist/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ __pycache__/
6
+ *.pyc
fluxup-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: fluxup
3
+ Version: 0.1.0
4
+ Summary: GreenPipe Flux bootstrap installer
5
+ Project-URL: Homepage, https://greenpipe.partners/install
6
+ Project-URL: Repository, https://github.com/GreenPipePartners/Fluxup
7
+ Author: GreenPipe Partners
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: System Administrators
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: System :: Installation/Setup
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # fluxup
18
+
19
+ `fluxup` is the small public bootstrap CLI for installing Flux from signed GreenPipe release artifacts.
20
+
21
+ ```bash
22
+ uvx fluxup init
23
+ ```
24
+
25
+ The package does not contain the Flux platform runtime. It downloads the selected Flux release artifact from GreenPipe, verifies checksum/signature metadata, unpacks it, and runs the native installer on the target host.
26
+
27
+ ## Commands
28
+
29
+ ```bash
30
+ fluxup init
31
+ fluxup init --version 0.1.0
32
+ fluxup init --manifest-url https://greenpipe.partners/api/flux/deployments/dep_123/manifest --claim-token TOKEN
33
+ fluxup plan
34
+ fluxup doctor
35
+ ```
36
+
37
+ Use `--dry-run` to verify the deployment flow without mutating system paths:
38
+
39
+ ```bash
40
+ fluxup init --dry-run --skip-signature
41
+ ```
42
+
43
+ `--skip-signature` should be limited to preview/testing installs.
fluxup-0.1.0/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # fluxup
2
+
3
+ `fluxup` is the small public bootstrap CLI for installing Flux from signed GreenPipe release artifacts.
4
+
5
+ ```bash
6
+ uvx fluxup init
7
+ ```
8
+
9
+ The package does not contain the Flux platform runtime. It downloads the selected Flux release artifact from GreenPipe, verifies checksum/signature metadata, unpacks it, and runs the native installer on the target host.
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ fluxup init
15
+ fluxup init --version 0.1.0
16
+ fluxup init --manifest-url https://greenpipe.partners/api/flux/deployments/dep_123/manifest --claim-token TOKEN
17
+ fluxup plan
18
+ fluxup doctor
19
+ ```
20
+
21
+ Use `--dry-run` to verify the deployment flow without mutating system paths:
22
+
23
+ ```bash
24
+ fluxup init --dry-run --skip-signature
25
+ ```
26
+
27
+ `--skip-signature` should be limited to preview/testing installs.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fluxup"
7
+ version = "0.1.0"
8
+ description = "GreenPipe Flux bootstrap installer"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [
12
+ { name = "GreenPipe Partners" },
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: System Administrators",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: System :: Installation/Setup",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://greenpipe.partners/install"
26
+ Repository = "https://github.com/GreenPipePartners/Fluxup"
27
+
28
+ [project.scripts]
29
+ fluxup = "fluxup.cli:main"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest>=8.0",
34
+ "ruff>=0.15.14",
35
+ ]
36
+
37
+ [tool.pytest.ini_options]
38
+ pythonpath = ["src"]
39
+
40
+ [tool.ruff]
41
+ line-length = 100
42
+ target-version = "py39"
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from fluxup import __version__
10
+ from fluxup.deploy import (
11
+ DEFAULT_PUBLIC_KEY_URL,
12
+ DEFAULT_RELEASE_ROOT,
13
+ DEFAULT_VERSION,
14
+ DEFAULT_WORK_DIR,
15
+ FluxupError,
16
+ InstallOptions,
17
+ apply,
18
+ doctor_payload,
19
+ plan,
20
+ )
21
+
22
+
23
+ def main(argv: Optional[list[str]] = None) -> int:
24
+ parser = build_parser()
25
+ args = parser.parse_args(argv)
26
+ try:
27
+ options = options_from_args(args)
28
+ if args.command == "init":
29
+ return apply(options)
30
+ if args.command == "plan":
31
+ print(json.dumps(plan(options), indent=2, sort_keys=True))
32
+ return 0
33
+ if args.command == "doctor":
34
+ payload = doctor_payload(options)
35
+ if args.json:
36
+ print(json.dumps(payload, indent=2, sort_keys=True))
37
+ else:
38
+ print_doctor(payload)
39
+ return 0 if all(payload["tools"].values()) else 1
40
+ except FluxupError as exc:
41
+ if getattr(args, "json", False):
42
+ print(json.dumps({"stage": "fluxup", "state": "error", "message": str(exc)}), file=sys.stderr)
43
+ else:
44
+ print("fluxup: %s" % exc, file=sys.stderr)
45
+ return 1
46
+ raise AssertionError("unhandled command")
47
+
48
+
49
+ def build_parser() -> argparse.ArgumentParser:
50
+ parser = argparse.ArgumentParser(description="Install Flux from GreenPipe release artifacts.")
51
+ parser.add_argument("--version", action="version", version="fluxup %s" % __version__)
52
+ subparsers = parser.add_subparsers(dest="command", required=True)
53
+
54
+ init_parser = subparsers.add_parser("init", help="Download, verify, and install Flux.")
55
+ add_manifest_args(init_parser)
56
+ add_release_args(init_parser)
57
+ add_install_args(init_parser)
58
+ init_parser.add_argument("--dry-run", action="store_true", help="Pass --dry-run to the native installer.")
59
+ init_parser.add_argument(
60
+ "--skip-signature", action="store_true", help="Skip GPG signature verification. Preview use only."
61
+ )
62
+ init_parser.add_argument("--artifact", type=Path, help="Use a local release artifact instead of downloading.")
63
+
64
+ plan_parser = subparsers.add_parser("plan", help="Print the manifest and installer command plan.")
65
+ add_manifest_args(plan_parser)
66
+ add_release_args(plan_parser)
67
+ add_install_args(plan_parser)
68
+
69
+ doctor_parser = subparsers.add_parser("doctor", help="Check local bootstrap prerequisites.")
70
+ add_release_args(doctor_parser)
71
+ doctor_parser.add_argument("--json", action="store_true", help="Emit JSON output.")
72
+ return parser
73
+
74
+
75
+ def add_manifest_args(parser: argparse.ArgumentParser) -> None:
76
+ source = parser.add_mutually_exclusive_group()
77
+ source.add_argument("--manifest-url", help="HTTPS URL returning a FluxInstall JSON manifest.")
78
+ source.add_argument("--manifest", type=Path, help="Local FluxInstall JSON manifest path.")
79
+ parser.add_argument("--claim-token", default="", help="One-time managed deployment token.")
80
+ parser.add_argument("--events-url", default="", help="Deployment event callback URL override.")
81
+ parser.add_argument("--work-dir", type=Path, default=DEFAULT_WORK_DIR)
82
+ parser.add_argument("--json", action="store_true", help="Emit JSON-lines status events.")
83
+
84
+
85
+ def add_release_args(parser: argparse.ArgumentParser) -> None:
86
+ parser.add_argument(
87
+ "--version",
88
+ "--flux-version",
89
+ dest="flux_version",
90
+ default=DEFAULT_VERSION,
91
+ help="Flux release version to install.",
92
+ )
93
+ parser.add_argument("--release-root", default=DEFAULT_RELEASE_ROOT, help="GreenPipe Flux release root URL.")
94
+ parser.add_argument(
95
+ "--public-key-url",
96
+ default=DEFAULT_PUBLIC_KEY_URL,
97
+ help="Release signing public key URL. Empty string disables key download.",
98
+ )
99
+
100
+
101
+ def add_install_args(parser: argparse.ArgumentParser) -> None:
102
+ parser.add_argument("--allowed-hosts", default="localhost,127.0.0.1")
103
+ parser.add_argument("--csrf-trusted-origins", default="")
104
+ parser.add_argument("--web-bind", default="0.0.0.0:8000")
105
+ parser.add_argument("--web-workers", type=int, default=8)
106
+ parser.add_argument("--web-threads", type=int, default=2)
107
+ parser.add_argument("--field-agent-base-port", type=int, default=4850)
108
+ parser.add_argument("--no-start", action="store_true", help="Do not start services after install.")
109
+ parser.add_argument("--no-enable", action="store_true", help="Do not enable services at boot.")
110
+ parser.add_argument("--external-postgres", action="store_true", help="Use FLUX_DATABASE_URL for Postgres.")
111
+ parser.add_argument("--database-url", default="", help="External DATABASE_URL. Prefer --database-url-env.")
112
+ parser.add_argument("--database-url-env", default="", help="Environment variable containing DATABASE_URL.")
113
+
114
+
115
+ def options_from_args(args: argparse.Namespace) -> InstallOptions:
116
+ return InstallOptions(
117
+ manifest_url=getattr(args, "manifest_url", None),
118
+ manifest_path=getattr(args, "manifest", None),
119
+ version=getattr(args, "flux_version", DEFAULT_VERSION),
120
+ release_root=getattr(args, "release_root", DEFAULT_RELEASE_ROOT),
121
+ public_key_url=getattr(args, "public_key_url", DEFAULT_PUBLIC_KEY_URL),
122
+ claim_token=getattr(args, "claim_token", ""),
123
+ events_url=getattr(args, "events_url", ""),
124
+ work_dir=getattr(args, "work_dir", DEFAULT_WORK_DIR),
125
+ json_output=getattr(args, "json", False),
126
+ dry_run=getattr(args, "dry_run", False),
127
+ skip_signature=getattr(args, "skip_signature", False),
128
+ artifact=getattr(args, "artifact", None),
129
+ allowed_hosts=getattr(args, "allowed_hosts", "localhost,127.0.0.1"),
130
+ csrf_trusted_origins=getattr(args, "csrf_trusted_origins", ""),
131
+ web_bind=getattr(args, "web_bind", "0.0.0.0:8000"),
132
+ web_workers=getattr(args, "web_workers", 8),
133
+ web_threads=getattr(args, "web_threads", 2),
134
+ field_agent_base_port=getattr(args, "field_agent_base_port", 4850),
135
+ start=not getattr(args, "no_start", False),
136
+ enable=not getattr(args, "no_enable", False),
137
+ database_url=getattr(args, "database_url", ""),
138
+ database_url_env=getattr(args, "database_url_env", ""),
139
+ external_postgres=getattr(args, "external_postgres", False),
140
+ )
141
+
142
+
143
+ def print_doctor(payload: dict) -> None:
144
+ print("Fluxup doctor")
145
+ print("distro: %s" % payload["distro"])
146
+ for name, present in sorted(payload["tools"].items()):
147
+ print("%s: %s" % (name, "ok" if present else "missing"))
148
+ print("release: %s" % payload["release"]["checksum_url"])
149
+
150
+
151
+ if __name__ == "__main__":
152
+ raise SystemExit(main())
@@ -0,0 +1,475 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import subprocess
10
+ import tempfile
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Optional
17
+
18
+ from fluxup import __version__
19
+
20
+
21
+ DEFAULT_VERSION = "0.1.0"
22
+ DEFAULT_RELEASE_ROOT = "https://greenpipe.partners/release/flux"
23
+ DEFAULT_PUBLIC_KEY_URL = "https://greenpipe.partners/release/flux/flux-release.pub"
24
+ DEFAULT_WORK_DIR = Path("/var/tmp/fluxup")
25
+ USER_AGENT = "fluxup/%s" % __version__
26
+
27
+
28
+ class FluxupError(RuntimeError):
29
+ pass
30
+
31
+
32
+ @dataclass
33
+ class InstallOptions:
34
+ manifest_url: Optional[str] = None
35
+ manifest_path: Optional[Path] = None
36
+ version: str = DEFAULT_VERSION
37
+ release_root: str = DEFAULT_RELEASE_ROOT
38
+ public_key_url: str = DEFAULT_PUBLIC_KEY_URL
39
+ claim_token: str = ""
40
+ events_url: str = ""
41
+ work_dir: Path = field(default_factory=lambda: DEFAULT_WORK_DIR)
42
+ json_output: bool = False
43
+ dry_run: bool = False
44
+ skip_signature: bool = False
45
+ artifact: Optional[Path] = None
46
+ allowed_hosts: str = "localhost,127.0.0.1"
47
+ csrf_trusted_origins: str = ""
48
+ web_bind: str = "0.0.0.0:8000"
49
+ web_workers: int = 8
50
+ web_threads: int = 2
51
+ field_agent_base_port: int = 4850
52
+ start: bool = True
53
+ enable: bool = True
54
+ database_url: str = ""
55
+ database_url_env: str = ""
56
+ external_postgres: bool = False
57
+
58
+
59
+ class Reporter:
60
+ def __init__(self, *, json_output: bool, events_url: str, claim_token: str) -> None:
61
+ self.json_output = json_output
62
+ self.events_url = events_url
63
+ self.claim_token = claim_token
64
+ self.deployment_id = ""
65
+
66
+ def emit(self, stage: str, state: str, message: str) -> None:
67
+ payload = {
68
+ "deployment_id": self.deployment_id,
69
+ "stage": stage,
70
+ "state": state,
71
+ "message": message,
72
+ "timestamp": dt.datetime.now(dt.timezone.utc).isoformat(),
73
+ }
74
+ if self.json_output:
75
+ print(json.dumps(payload, sort_keys=True), flush=True)
76
+ else:
77
+ print("[%s] %s: %s" % (state.upper(), stage, message), flush=True)
78
+ self.post_event(payload)
79
+
80
+ def post_event(self, payload: Dict[str, Any]) -> None:
81
+ if not self.events_url:
82
+ return
83
+ data = json.dumps(payload).encode("utf-8")
84
+ headers = request_headers(self.claim_token, accept="application/json")
85
+ headers["Content-Type"] = "application/json"
86
+ request = urllib.request.Request(self.events_url, data=data, headers=headers, method="POST")
87
+ try:
88
+ with urllib.request.urlopen(request, timeout=10):
89
+ return
90
+ except urllib.error.URLError:
91
+ return
92
+
93
+
94
+ def plan(options: InstallOptions) -> Dict[str, Any]:
95
+ manifest = load_manifest(options)
96
+ return {
97
+ "manifest": manifest.get("metadata", {}),
98
+ "release": manifest["spec"]["release"],
99
+ "installer_args": build_installer_args(manifest, dry_run=True),
100
+ }
101
+
102
+
103
+ def apply(options: InstallOptions) -> int:
104
+ reporter = Reporter(
105
+ json_output=options.json_output,
106
+ events_url=options.events_url,
107
+ claim_token=options.claim_token,
108
+ )
109
+ manifest = load_manifest(options)
110
+ metadata = manifest.get("metadata", {})
111
+ release = manifest["spec"]["release"]
112
+ deployment_id = str(metadata.get("deployment_id", "manual"))
113
+ reporter.deployment_id = deployment_id
114
+ if not reporter.events_url:
115
+ reporter.events_url = manifest.get("spec", {}).get("reporting", {}).get("events_url", "")
116
+
117
+ run_dir = options.work_dir / safe_name(deployment_id)
118
+ download_dir = run_dir / "download"
119
+ unpack_dir = run_dir / "unpack"
120
+ download_dir.mkdir(parents=True, exist_ok=True)
121
+
122
+ reporter.emit("manifest", "ok", "loaded FluxInstall manifest")
123
+ if options.dry_run:
124
+ reporter.emit("preflight", "ok", "dry-run selected; host mutations limited to installer dry-run")
125
+ else:
126
+ ensure_unpack_tools(reporter)
127
+
128
+ artifact = options.artifact or download_artifact(release, download_dir, reporter)
129
+ verify_sha256(artifact, release["sha256"])
130
+ reporter.emit("artifact", "ok", "verified artifact checksum")
131
+
132
+ verify_release_signature(options, release, artifact, download_dir, reporter)
133
+
134
+ reset_dir(unpack_dir)
135
+ unpack_artifact(artifact, unpack_dir, reporter)
136
+ source_dir = find_source_dir(unpack_dir, str(release["version"]))
137
+ reporter.emit("unpack", "ok", "unpacked release artifact to %s" % source_dir)
138
+
139
+ command = build_installer_args(manifest, dry_run=options.dry_run)
140
+ reporter.emit("installer", "running", "running native installer")
141
+ run_subprocess(command, cwd=source_dir)
142
+ reporter.emit("installer", "ok", "native installer completed")
143
+ return 0
144
+
145
+
146
+ def load_manifest(options: InstallOptions) -> Dict[str, Any]:
147
+ if options.manifest_url:
148
+ raw = read_url(options.manifest_url, token=options.claim_token)
149
+ manifest = parse_manifest(raw, "manifest URL")
150
+ elif options.manifest_path:
151
+ manifest = parse_manifest(options.manifest_path.read_bytes(), str(options.manifest_path))
152
+ else:
153
+ manifest = build_public_manifest(options)
154
+ validate_manifest(manifest)
155
+ return manifest
156
+
157
+
158
+ def parse_manifest(raw: bytes, source: str) -> Dict[str, Any]:
159
+ try:
160
+ loaded = json.loads(raw.decode("utf-8"))
161
+ except json.JSONDecodeError as exc:
162
+ raise FluxupError("FluxInstall manifests must be JSON: %s: %s" % (source, exc)) from exc
163
+ if not isinstance(loaded, dict):
164
+ raise FluxupError("FluxInstall manifest must be a JSON object: %s" % source)
165
+ return loaded
166
+
167
+
168
+ def build_public_manifest(options: InstallOptions) -> Dict[str, Any]:
169
+ version = options.version
170
+ root = options.release_root.rstrip("/")
171
+ release_dir = "%s/%s" % (root, version)
172
+ artifact_name = "flux-%s.tar.zst" % version
173
+ artifact_url = "%s/%s" % (release_dir, artifact_name)
174
+ checksum_url = "%s/%s.sha256" % (release_dir, artifact_name)
175
+ signature_url = "%s/%s.sig" % (release_dir, artifact_name)
176
+ checksum_text = read_url(checksum_url).decode("utf-8")
177
+ release = {
178
+ "version": version,
179
+ "artifact_url": artifact_url,
180
+ "sha256": parse_checksum_text(checksum_text, artifact_name),
181
+ "checksum_url": checksum_url,
182
+ "signature_url": signature_url,
183
+ }
184
+ if options.public_key_url:
185
+ release["public_key_url"] = options.public_key_url
186
+ return {
187
+ "apiVersion": "flux.greenpipe.partners/v1",
188
+ "kind": "FluxInstall",
189
+ "metadata": {"deployment_id": "public-%s" % version, "site": "public"},
190
+ "spec": {
191
+ "release": release,
192
+ "target": generated_target(options),
193
+ "database": generated_database(options),
194
+ "services": generated_services(options),
195
+ },
196
+ }
197
+
198
+
199
+ def generated_target(options: InstallOptions) -> Dict[str, Any]:
200
+ target = {"allowed_hosts": options.allowed_hosts, "web_bind": options.web_bind}
201
+ if options.csrf_trusted_origins:
202
+ target["csrf_trusted_origins"] = options.csrf_trusted_origins
203
+ return target
204
+
205
+
206
+ def generated_database(options: InstallOptions) -> Dict[str, Any]:
207
+ if options.database_url:
208
+ return {"mode": "external", "database_url": options.database_url}
209
+ if options.database_url_env:
210
+ return {"mode": "external", "database_url_env": options.database_url_env}
211
+ if options.external_postgres:
212
+ return {"mode": "external", "database_url_env": "FLUX_DATABASE_URL"}
213
+ return {"mode": "local"}
214
+
215
+
216
+ def generated_services(options: InstallOptions) -> Dict[str, Any]:
217
+ return {
218
+ "enable": options.enable,
219
+ "start": options.start,
220
+ "web_workers": options.web_workers,
221
+ "web_threads": options.web_threads,
222
+ "field_agent_base_port": options.field_agent_base_port,
223
+ }
224
+
225
+
226
+ def validate_manifest(manifest: Dict[str, Any]) -> None:
227
+ if manifest.get("apiVersion") != "flux.greenpipe.partners/v1":
228
+ raise FluxupError("unsupported apiVersion")
229
+ if manifest.get("kind") != "FluxInstall":
230
+ raise FluxupError("unsupported manifest kind")
231
+ spec = required_dict(manifest, "spec")
232
+ release = required_dict(spec, "release")
233
+ for key in ("version", "artifact_url", "sha256"):
234
+ if not release.get(key):
235
+ raise FluxupError("manifest spec.release.%s is required" % key)
236
+
237
+
238
+ def required_dict(value: Dict[str, Any], key: str) -> Dict[str, Any]:
239
+ child = value.get(key)
240
+ if not isinstance(child, dict):
241
+ raise FluxupError("manifest %s must be an object" % key)
242
+ return child
243
+
244
+
245
+ def parse_checksum_text(text: str, filename: str) -> str:
246
+ fallback = ""
247
+ for raw_line in text.splitlines():
248
+ line = raw_line.strip()
249
+ if not line or line.startswith("#"):
250
+ continue
251
+ parts = line.split()
252
+ digest = parts[0].lower()
253
+ if not is_sha256_digest(digest):
254
+ continue
255
+ if len(parts) == 1:
256
+ fallback = digest
257
+ continue
258
+ candidate_name = parts[-1].lstrip("*")
259
+ if Path(candidate_name).name == filename:
260
+ return digest
261
+ fallback = fallback or digest
262
+ if fallback:
263
+ return fallback
264
+ raise FluxupError("checksum file did not contain a valid sha256 for %s" % filename)
265
+
266
+
267
+ def is_sha256_digest(value: str) -> bool:
268
+ return len(value) == 64 and all(char in "0123456789abcdef" for char in value.lower())
269
+
270
+
271
+ def read_url(url: str, token: str = "", *, timeout: int = 60) -> bytes:
272
+ request = urllib.request.Request(url, headers=request_headers(token))
273
+ try:
274
+ with urllib.request.urlopen(request, timeout=timeout) as response:
275
+ return response.read()
276
+ except urllib.error.URLError as exc:
277
+ raise FluxupError("failed to fetch %s: %s" % (url, exc)) from exc
278
+
279
+
280
+ def request_headers(token: str = "", *, accept: str = "application/octet-stream") -> Dict[str, str]:
281
+ headers = {"Accept": accept, "User-Agent": USER_AGENT}
282
+ if token:
283
+ headers["Authorization"] = "Bearer %s" % token
284
+ headers["X-Flux-Claim-Token"] = token
285
+ return headers
286
+
287
+
288
+ def download_artifact(release: Dict[str, Any], download_dir: Path, reporter: Reporter) -> Path:
289
+ url = str(release["artifact_url"])
290
+ filename = Path(urllib.parse.urlparse(url).path).name
291
+ if not filename:
292
+ raise FluxupError("artifact_url must end with a filename")
293
+ target = download_dir / filename
294
+ reporter.emit("artifact", "running", "downloading %s" % url)
295
+ request = urllib.request.Request(url, headers=request_headers())
296
+ try:
297
+ with urllib.request.urlopen(request, timeout=120) as response, target.open("wb") as file_obj:
298
+ shutil.copyfileobj(response, file_obj)
299
+ except urllib.error.URLError as exc:
300
+ raise FluxupError("failed to download artifact: %s" % exc) from exc
301
+ return target
302
+
303
+
304
+ def verify_sha256(path: Path, expected: str) -> None:
305
+ normalized = expected.strip().lower()
306
+ if not is_sha256_digest(normalized):
307
+ raise FluxupError("manifest sha256 must be a 64-character hex digest")
308
+ digest = hashlib.sha256()
309
+ with path.open("rb") as file_obj:
310
+ for chunk in iter(lambda: file_obj.read(1024 * 1024), b""):
311
+ digest.update(chunk)
312
+ actual = digest.hexdigest()
313
+ if actual != normalized:
314
+ raise FluxupError("artifact checksum mismatch: expected %s got %s" % (normalized, actual))
315
+
316
+
317
+ def verify_release_signature(
318
+ options: InstallOptions,
319
+ release: Dict[str, Any],
320
+ artifact: Path,
321
+ download_dir: Path,
322
+ reporter: Reporter,
323
+ ) -> None:
324
+ if options.skip_signature:
325
+ reporter.emit("signature", "skipped", "signature verification skipped by operator flag")
326
+ return
327
+ signature_url = release.get("signature_url")
328
+ if not signature_url:
329
+ raise FluxupError("manifest spec.release.signature_url is required unless --skip-signature")
330
+ if not shutil.which("gpg"):
331
+ raise FluxupError("gpg is required for signature verification")
332
+ signature = download_file(str(signature_url), download_dir, reporter, "signature")
333
+ public_key_url = release.get("public_key_url")
334
+ if public_key_url:
335
+ public_key = download_file(str(public_key_url), download_dir, reporter, "public-key")
336
+ verify_signature_with_temp_keyring(signature, artifact, public_key)
337
+ else:
338
+ run_subprocess(["gpg", "--verify", str(signature), str(artifact)])
339
+ reporter.emit("signature", "ok", "verified release signature")
340
+
341
+
342
+ def download_file(url: str, download_dir: Path, reporter: Reporter, stage: str) -> Path:
343
+ filename = Path(urllib.parse.urlparse(url).path).name
344
+ if not filename:
345
+ raise FluxupError("URL must end with a filename: %s" % url)
346
+ target = download_dir / filename
347
+ reporter.emit(stage, "running", "downloading %s" % url)
348
+ request = urllib.request.Request(url, headers=request_headers())
349
+ try:
350
+ with urllib.request.urlopen(request, timeout=60) as response, target.open("wb") as file_obj:
351
+ shutil.copyfileobj(response, file_obj)
352
+ except urllib.error.URLError as exc:
353
+ raise FluxupError("failed to download %s: %s" % (url, exc)) from exc
354
+ return target
355
+
356
+
357
+ def verify_signature_with_temp_keyring(signature: Path, artifact: Path, public_key: Path) -> None:
358
+ with tempfile.TemporaryDirectory(prefix="fluxup-gnupg-") as temp_name:
359
+ gnupg_home = Path(temp_name)
360
+ gnupg_home.chmod(stat.S_IRWXU)
361
+ run_subprocess(["gpg", "--homedir", str(gnupg_home), "--batch", "--import", str(public_key)])
362
+ run_subprocess(
363
+ ["gpg", "--homedir", str(gnupg_home), "--batch", "--verify", str(signature), str(artifact)]
364
+ )
365
+
366
+
367
+ def ensure_unpack_tools(reporter: Reporter) -> None:
368
+ if shutil.which("tar") and shutil.which("zstd"):
369
+ return
370
+ if hasattr(os, "geteuid") and os.geteuid() != 0:
371
+ raise FluxupError("tar and zstd are required; rerun with sudo so dependencies can be installed")
372
+ distro = detect_distro_family()
373
+ reporter.emit("bootstrap-packages", "running", "installing tar/zstd bootstrap dependencies")
374
+ if distro == "apt":
375
+ run_subprocess(["apt-get", "update"])
376
+ run_subprocess(["apt-get", "install", "-y", "tar", "zstd", "ca-certificates", "gnupg"])
377
+ elif distro == "dnf":
378
+ manager = shutil.which("dnf") or shutil.which("yum") or "dnf"
379
+ run_subprocess([manager, "install", "-y", "tar", "zstd", "ca-certificates", "gnupg"])
380
+ else:
381
+ raise FluxupError("unsupported distro for bootstrap dependency install")
382
+
383
+
384
+ def detect_distro_family() -> str:
385
+ os_release = Path("/etc/os-release")
386
+ data = {}
387
+ if os_release.exists():
388
+ for line in os_release.read_text(encoding="utf-8").splitlines():
389
+ if "=" not in line or line.startswith("#"):
390
+ continue
391
+ key, value = line.split("=", 1)
392
+ data[key] = value.strip().strip('"').lower()
393
+ ids = set([data.get("ID", "")]) | set(data.get("ID_LIKE", "").split())
394
+ if ids & {"ubuntu", "debian"}:
395
+ return "apt"
396
+ if ids & {"rhel", "fedora", "centos", "rocky", "almalinux"}:
397
+ return "dnf"
398
+ return "unknown"
399
+
400
+
401
+ def reset_dir(path: Path) -> None:
402
+ if path.exists():
403
+ shutil.rmtree(path)
404
+ path.mkdir(parents=True)
405
+
406
+
407
+ def unpack_artifact(artifact: Path, unpack_dir: Path, reporter: Reporter) -> None:
408
+ reporter.emit("unpack", "running", "extracting %s" % artifact.name)
409
+ run_subprocess(["tar", "--zstd", "-xf", str(artifact), "-C", str(unpack_dir)])
410
+
411
+
412
+ def find_source_dir(unpack_dir: Path, version: str) -> Path:
413
+ expected = unpack_dir / ("flux-%s" % version)
414
+ if expected.exists():
415
+ return expected
416
+ dirs = [path for path in unpack_dir.iterdir() if path.is_dir()]
417
+ if len(dirs) == 1:
418
+ return dirs[0]
419
+ raise FluxupError("artifact must unpack to one source directory")
420
+
421
+
422
+ def build_installer_args(manifest: Dict[str, Any], *, dry_run: bool) -> list[str]:
423
+ spec = manifest["spec"]
424
+ target = spec.get("target", {})
425
+ database = spec.get("database", {})
426
+ services = spec.get("services", {})
427
+ command = ["python3", "install/flux_installer.py"]
428
+ if dry_run:
429
+ command.append("--dry-run")
430
+ if services.get("start"):
431
+ command.append("--start")
432
+ if services.get("enable") is False:
433
+ command.append("--no-enable")
434
+ add_option(command, "--allowed-hosts", target.get("allowed_hosts"))
435
+ add_option(command, "--csrf-trusted-origins", target.get("csrf_trusted_origins"))
436
+ add_option(command, "--web-bind", target.get("web_bind"))
437
+ add_option(command, "--web-workers", services.get("web_workers"))
438
+ add_option(command, "--web-threads", services.get("web_threads"))
439
+ add_option(command, "--field-agent-base-port", services.get("field_agent_base_port"))
440
+ if database.get("mode") == "external":
441
+ database_url = database.get("database_url") or os.environ.get(
442
+ database.get("database_url_env", "FLUX_DATABASE_URL")
443
+ )
444
+ if not database_url:
445
+ raise FluxupError("external database mode requires database_url or FLUX_DATABASE_URL")
446
+ command.extend(["--database-url", str(database_url), "--skip-postgres-setup"])
447
+ return command
448
+
449
+
450
+ def add_option(command: list[str], flag: str, value: Any) -> None:
451
+ if value is None or value == "":
452
+ return
453
+ command.extend([flag, str(value)])
454
+
455
+
456
+ def run_subprocess(command: list[str], *, cwd: Optional[Path] = None) -> None:
457
+ try:
458
+ subprocess.run(command, cwd=str(cwd) if cwd else None, check=True)
459
+ except subprocess.CalledProcessError as exc:
460
+ raise FluxupError("command failed with exit code %s: %s" % (exc.returncode, command)) from exc
461
+
462
+
463
+ def safe_name(value: str) -> str:
464
+ return "".join(char if char.isalnum() or char in "._-" else "_" for char in value) or "manual"
465
+
466
+
467
+ def doctor_payload(options: InstallOptions) -> Dict[str, Any]:
468
+ tools = {name: bool(shutil.which(name)) for name in ("python3", "tar", "zstd", "gpg")}
469
+ release = {
470
+ "version": options.version,
471
+ "release_root": options.release_root.rstrip("/"),
472
+ "checksum_url": "%s/%s/flux-%s.tar.zst.sha256"
473
+ % (options.release_root.rstrip("/"), options.version, options.version),
474
+ }
475
+ return {"distro": detect_distro_family(), "tools": tools, "release": release}
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from fluxup import cli, deploy
10
+ from fluxup.deploy import FluxupError, InstallOptions
11
+
12
+
13
+ def test_parse_checksum_text_selects_named_artifact() -> None:
14
+ expected = "a" * 64
15
+ other = "b" * 64
16
+ text = "%s other.tar.zst\n%s flux-0.1.0.tar.zst\n" % (other, expected)
17
+ assert deploy.parse_checksum_text(text, "flux-0.1.0.tar.zst") == expected
18
+
19
+
20
+ def test_verify_sha256_accepts_matching_file(tmp_path: Path) -> None:
21
+ artifact = tmp_path / "artifact.tar.zst"
22
+ artifact.write_bytes(b"flux")
23
+ deploy.verify_sha256(artifact, hashlib.sha256(b"flux").hexdigest())
24
+
25
+
26
+ def test_verify_sha256_rejects_mismatch(tmp_path: Path) -> None:
27
+ artifact = tmp_path / "artifact.tar.zst"
28
+ artifact.write_bytes(b"flux")
29
+ with pytest.raises(FluxupError):
30
+ deploy.verify_sha256(artifact, "0" * 64)
31
+
32
+
33
+ def test_build_public_manifest_uses_greenpipe_release_urls(monkeypatch: pytest.MonkeyPatch) -> None:
34
+ digest = "c" * 64
35
+
36
+ def fake_read_url(url: str, token: str = "", *, timeout: int = 60) -> bytes:
37
+ assert url == "https://greenpipe.partners/release/flux/0.1.0/flux-0.1.0.tar.zst.sha256"
38
+ return ("%s flux-0.1.0.tar.zst\n" % digest).encode()
39
+
40
+ monkeypatch.setattr(deploy, "read_url", fake_read_url)
41
+ manifest = deploy.build_public_manifest(InstallOptions())
42
+ release = manifest["spec"]["release"]
43
+ assert release["artifact_url"] == "https://greenpipe.partners/release/flux/0.1.0/flux-0.1.0.tar.zst"
44
+ assert release["checksum_url"].endswith("flux-0.1.0.tar.zst.sha256")
45
+ assert release["signature_url"].endswith("flux-0.1.0.tar.zst.sig")
46
+ assert release["public_key_url"] == "https://greenpipe.partners/release/flux/flux-release.pub"
47
+ assert release["sha256"] == digest
48
+
49
+
50
+ def test_build_installer_args_maps_manifest_to_native_installer(monkeypatch: pytest.MonkeyPatch) -> None:
51
+ monkeypatch.setenv("FLUX_DATABASE_URL", "postgres://flux:secret@db/flux")
52
+ manifest = {
53
+ "apiVersion": "flux.greenpipe.partners/v1",
54
+ "kind": "FluxInstall",
55
+ "spec": {
56
+ "release": {
57
+ "version": "0.1.0",
58
+ "artifact_url": "https://example.test/flux-0.1.0.tar.zst",
59
+ "sha256": "d" * 64,
60
+ },
61
+ "target": {"allowed_hosts": "flux.example", "web_bind": "0.0.0.0:9000"},
62
+ "database": {"mode": "external", "database_url_env": "FLUX_DATABASE_URL"},
63
+ "services": {"enable": False, "start": True, "web_workers": 4, "web_threads": 1},
64
+ },
65
+ }
66
+ command = deploy.build_installer_args(manifest, dry_run=True)
67
+ assert command[:3] == ["python3", "install/flux_installer.py", "--dry-run"]
68
+ assert "--start" in command
69
+ assert "--no-enable" in command
70
+ assert command[command.index("--allowed-hosts") + 1] == "flux.example"
71
+ assert command[command.index("--web-bind") + 1] == "0.0.0.0:9000"
72
+ assert command[command.index("--database-url") + 1] == "postgres://flux:secret@db/flux"
73
+ assert "--skip-postgres-setup" in command
74
+
75
+
76
+ def test_cli_plan_accepts_local_manifest(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
77
+ manifest = {
78
+ "apiVersion": "flux.greenpipe.partners/v1",
79
+ "kind": "FluxInstall",
80
+ "metadata": {"deployment_id": "dep_test"},
81
+ "spec": {
82
+ "release": {
83
+ "version": "0.1.0",
84
+ "artifact_url": "https://example.test/flux-0.1.0.tar.zst",
85
+ "sha256": "e" * 64,
86
+ },
87
+ "target": {"allowed_hosts": "localhost"},
88
+ "database": {"mode": "local"},
89
+ "services": {"enable": True, "start": False},
90
+ },
91
+ }
92
+ manifest_path = tmp_path / "manifest.json"
93
+ manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
94
+ assert cli.main(["plan", "--manifest", str(manifest_path)]) == 0
95
+ payload = json.loads(capsys.readouterr().out)
96
+ assert payload["manifest"] == {"deployment_id": "dep_test"}
97
+ assert payload["installer_args"][0:2] == ["python3", "install/flux_installer.py"]
98
+
99
+
100
+ def test_cli_init_version_selects_flux_release_version() -> None:
101
+ args = cli.build_parser().parse_args(["init", "--version", "0.2.0"])
102
+ options = cli.options_from_args(args)
103
+ assert options.version == "0.2.0"
fluxup-0.1.0/uv.lock ADDED
@@ -0,0 +1,229 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.9"
4
+ resolution-markers = [
5
+ "python_full_version >= '3.10'",
6
+ "python_full_version < '3.10'",
7
+ ]
8
+
9
+ [[package]]
10
+ name = "colorama"
11
+ version = "0.4.6"
12
+ source = { registry = "https://pypi.org/simple" }
13
+ 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" }
14
+ wheels = [
15
+ { 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" },
16
+ ]
17
+
18
+ [[package]]
19
+ name = "exceptiongroup"
20
+ version = "1.3.1"
21
+ source = { registry = "https://pypi.org/simple" }
22
+ dependencies = [
23
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
24
+ ]
25
+ 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" }
26
+ wheels = [
27
+ { 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" },
28
+ ]
29
+
30
+ [[package]]
31
+ name = "fluxup"
32
+ version = "0.1.0"
33
+ source = { editable = "." }
34
+
35
+ [package.dev-dependencies]
36
+ dev = [
37
+ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
38
+ { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
39
+ { name = "ruff" },
40
+ ]
41
+
42
+ [package.metadata]
43
+
44
+ [package.metadata.requires-dev]
45
+ dev = [
46
+ { name = "pytest", specifier = ">=8.0" },
47
+ { name = "ruff", specifier = ">=0.15.14" },
48
+ ]
49
+
50
+ [[package]]
51
+ name = "iniconfig"
52
+ version = "2.1.0"
53
+ source = { registry = "https://pypi.org/simple" }
54
+ resolution-markers = [
55
+ "python_full_version < '3.10'",
56
+ ]
57
+ sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
58
+ wheels = [
59
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
60
+ ]
61
+
62
+ [[package]]
63
+ name = "iniconfig"
64
+ version = "2.3.0"
65
+ source = { registry = "https://pypi.org/simple" }
66
+ resolution-markers = [
67
+ "python_full_version >= '3.10'",
68
+ ]
69
+ 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" }
70
+ wheels = [
71
+ { 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" },
72
+ ]
73
+
74
+ [[package]]
75
+ name = "packaging"
76
+ version = "26.2"
77
+ source = { registry = "https://pypi.org/simple" }
78
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
79
+ wheels = [
80
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
81
+ ]
82
+
83
+ [[package]]
84
+ name = "pluggy"
85
+ version = "1.6.0"
86
+ source = { registry = "https://pypi.org/simple" }
87
+ 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" }
88
+ wheels = [
89
+ { 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" },
90
+ ]
91
+
92
+ [[package]]
93
+ name = "pygments"
94
+ version = "2.20.0"
95
+ source = { registry = "https://pypi.org/simple" }
96
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
97
+ wheels = [
98
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
99
+ ]
100
+
101
+ [[package]]
102
+ name = "pytest"
103
+ version = "8.4.2"
104
+ source = { registry = "https://pypi.org/simple" }
105
+ resolution-markers = [
106
+ "python_full_version < '3.10'",
107
+ ]
108
+ dependencies = [
109
+ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
110
+ { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
111
+ { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
112
+ { name = "packaging", marker = "python_full_version < '3.10'" },
113
+ { name = "pluggy", marker = "python_full_version < '3.10'" },
114
+ { name = "pygments", marker = "python_full_version < '3.10'" },
115
+ { name = "tomli", marker = "python_full_version < '3.10'" },
116
+ ]
117
+ sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
118
+ wheels = [
119
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
120
+ ]
121
+
122
+ [[package]]
123
+ name = "pytest"
124
+ version = "9.1.1"
125
+ source = { registry = "https://pypi.org/simple" }
126
+ resolution-markers = [
127
+ "python_full_version >= '3.10'",
128
+ ]
129
+ dependencies = [
130
+ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
131
+ { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
132
+ { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
133
+ { name = "packaging", marker = "python_full_version >= '3.10'" },
134
+ { name = "pluggy", marker = "python_full_version >= '3.10'" },
135
+ { name = "pygments", marker = "python_full_version >= '3.10'" },
136
+ { name = "tomli", marker = "python_full_version == '3.10.*'" },
137
+ ]
138
+ 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" }
139
+ wheels = [
140
+ { 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" },
141
+ ]
142
+
143
+ [[package]]
144
+ name = "ruff"
145
+ version = "0.15.18"
146
+ source = { registry = "https://pypi.org/simple" }
147
+ sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" }
148
+ wheels = [
149
+ { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" },
150
+ { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" },
151
+ { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" },
152
+ { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" },
153
+ { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" },
154
+ { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" },
155
+ { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" },
156
+ { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" },
157
+ { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" },
158
+ { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" },
159
+ { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" },
160
+ { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" },
161
+ { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" },
162
+ { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" },
163
+ { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" },
164
+ { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" },
165
+ { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" },
166
+ ]
167
+
168
+ [[package]]
169
+ name = "tomli"
170
+ version = "2.4.1"
171
+ source = { registry = "https://pypi.org/simple" }
172
+ 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" }
173
+ wheels = [
174
+ { 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" },
175
+ { 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" },
176
+ { 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" },
177
+ { 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" },
178
+ { 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" },
179
+ { 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" },
180
+ { 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" },
181
+ { 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" },
182
+ { 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" },
183
+ { 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" },
184
+ { 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" },
185
+ { 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" },
186
+ { 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" },
187
+ { 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" },
188
+ { 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" },
189
+ { 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" },
190
+ { 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" },
191
+ { 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" },
192
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
193
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
194
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
195
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
196
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
197
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
198
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
199
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
200
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
201
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
202
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
203
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
204
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
205
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
206
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
207
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
208
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
209
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
210
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
211
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
212
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
213
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
214
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
215
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
216
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
217
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
218
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
219
+ { 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" },
220
+ ]
221
+
222
+ [[package]]
223
+ name = "typing-extensions"
224
+ version = "4.15.0"
225
+ source = { registry = "https://pypi.org/simple" }
226
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
227
+ wheels = [
228
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
229
+ ]