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.
- fluxup-0.1.0/.github/workflows/publish.yml +69 -0
- fluxup-0.1.0/.gitignore +6 -0
- fluxup-0.1.0/PKG-INFO +43 -0
- fluxup-0.1.0/README.md +27 -0
- fluxup-0.1.0/pyproject.toml +42 -0
- fluxup-0.1.0/src/fluxup/__init__.py +3 -0
- fluxup-0.1.0/src/fluxup/cli.py +152 -0
- fluxup-0.1.0/src/fluxup/deploy.py +475 -0
- fluxup-0.1.0/tests/test_deploy.py +103 -0
- fluxup-0.1.0/uv.lock +229 -0
|
@@ -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
|
fluxup-0.1.0/.gitignore
ADDED
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,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
|
+
]
|