slime-mc 1.0.0__py3-none-any.whl
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.
- slime/__init__.py +10 -0
- slime/__main__.py +6 -0
- slime/api/__init__.py +1 -0
- slime/api/adoptium.py +211 -0
- slime/api/papermc.py +256 -0
- slime/checks.py +413 -0
- slime/cli.py +1248 -0
- slime/config.py +230 -0
- slime/errors.py +48 -0
- slime/java.py +279 -0
- slime/logging_setup.py +39 -0
- slime/models.py +361 -0
- slime/process.py +323 -0
- slime/properties.py +196 -0
- slime/provision.py +165 -0
- slime/store.py +145 -0
- slime/tui/__init__.py +5 -0
- slime/tui/app.py +184 -0
- slime/tui/app.tcss +222 -0
- slime/tui/screens/__init__.py +1 -0
- slime/tui/screens/confirm.py +48 -0
- slime/tui/screens/create.py +280 -0
- slime/tui/screens/dashboard.py +122 -0
- slime/tui/screens/doctor.py +112 -0
- slime/tui/screens/java.py +222 -0
- slime/tui/screens/server.py +219 -0
- slime/utils/__init__.py +1 -0
- slime/utils/archives.py +97 -0
- slime/utils/downloads.py +132 -0
- slime_mc-1.0.0.dist-info/METADATA +423 -0
- slime_mc-1.0.0.dist-info/RECORD +34 -0
- slime_mc-1.0.0.dist-info/WHEEL +4 -0
- slime_mc-1.0.0.dist-info/entry_points.txt +2 -0
- slime_mc-1.0.0.dist-info/licenses/LICENSE +21 -0
slime/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Slime — a friendly Minecraft server manager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.0"
|
|
6
|
+
APP_NAME = "Slime"
|
|
7
|
+
APP_TAGLINE = "Minecraft server manager"
|
|
8
|
+
PROJECT_URL = "https://github.com/cizik5064-bit/slime"
|
|
9
|
+
|
|
10
|
+
__all__ = ["APP_NAME", "APP_TAGLINE", "PROJECT_URL", "__version__"]
|
slime/__main__.py
ADDED
slime/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP clients for the remote services Slime uses."""
|
slime/api/adoptium.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Client for the Eclipse Adoptium (Temurin) API v3.
|
|
2
|
+
|
|
3
|
+
Documentation: https://api.adoptium.net/q/swagger-ui/
|
|
4
|
+
|
|
5
|
+
Slime downloads the *archive* package (zip on Windows, tar.gz on Linux/macOS)
|
|
6
|
+
rather than the installer, extracts it into ``~/.slime/java`` and records the
|
|
7
|
+
exact ``bin/java`` path for each server.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import platform
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .. import __version__
|
|
19
|
+
from ..errors import ApiError
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.adoptium.net/v3"
|
|
22
|
+
|
|
23
|
+
#: Adoptium's platform identifiers.
|
|
24
|
+
_OS_ALIASES = {
|
|
25
|
+
"windows": "windows",
|
|
26
|
+
"win32": "windows",
|
|
27
|
+
"cygwin": "windows",
|
|
28
|
+
"linux": "linux",
|
|
29
|
+
"darwin": "mac",
|
|
30
|
+
"macos": "mac",
|
|
31
|
+
"mac": "mac",
|
|
32
|
+
}
|
|
33
|
+
_ARCH_ALIASES = {
|
|
34
|
+
"amd64": "x64",
|
|
35
|
+
"x86_64": "x64",
|
|
36
|
+
"x64": "x64",
|
|
37
|
+
"i386": "x86",
|
|
38
|
+
"i686": "x86",
|
|
39
|
+
"x86": "x86",
|
|
40
|
+
"aarch64": "aarch64",
|
|
41
|
+
"arm64": "aarch64",
|
|
42
|
+
"armv7l": "arm",
|
|
43
|
+
"ppc64le": "ppc64le",
|
|
44
|
+
"s390x": "s390x",
|
|
45
|
+
"riscv64": "riscv64",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def current_os() -> str:
|
|
50
|
+
system = platform.system().lower()
|
|
51
|
+
return _OS_ALIASES.get(system, system)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def current_arch() -> str:
|
|
55
|
+
machine = platform.machine().lower()
|
|
56
|
+
return _ARCH_ALIASES.get(machine, machine)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class JavaPackage:
|
|
61
|
+
"""A downloadable Temurin JDK archive."""
|
|
62
|
+
|
|
63
|
+
major: int
|
|
64
|
+
version: str
|
|
65
|
+
os: str
|
|
66
|
+
arch: str
|
|
67
|
+
name: str
|
|
68
|
+
url: str
|
|
69
|
+
sha256: str | None = None
|
|
70
|
+
size: int | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _as_int(value: Any) -> int | None:
|
|
74
|
+
try:
|
|
75
|
+
return int(value)
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AdoptiumClient:
|
|
81
|
+
"""Thin synchronous client around the Adoptium v3 API."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
86
|
+
*,
|
|
87
|
+
user_agent: str = f"Slime/{__version__}",
|
|
88
|
+
timeout: float = 30.0,
|
|
89
|
+
client: httpx.Client | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.base_url = base_url.rstrip("/")
|
|
92
|
+
self.user_agent = user_agent
|
|
93
|
+
self._client = client or httpx.Client(
|
|
94
|
+
follow_redirects=True,
|
|
95
|
+
timeout=timeout,
|
|
96
|
+
headers={"User-Agent": user_agent, "Accept": "application/json"},
|
|
97
|
+
)
|
|
98
|
+
self._owns_client = client is None
|
|
99
|
+
|
|
100
|
+
def close(self) -> None:
|
|
101
|
+
if self._owns_client:
|
|
102
|
+
self._client.close()
|
|
103
|
+
|
|
104
|
+
def __enter__(self) -> AdoptiumClient:
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def __exit__(self, *_: object) -> None:
|
|
108
|
+
self.close()
|
|
109
|
+
|
|
110
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
111
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
112
|
+
try:
|
|
113
|
+
response = self._client.get(url, params=params)
|
|
114
|
+
except httpx.HTTPError as exc:
|
|
115
|
+
raise ApiError(
|
|
116
|
+
f"Could not reach the Adoptium API: {exc}",
|
|
117
|
+
hint="Check your internet connection or try again in a minute.",
|
|
118
|
+
) from exc
|
|
119
|
+
if response.status_code == 404:
|
|
120
|
+
raise ApiError(
|
|
121
|
+
f"Adoptium has no package for this request ({path}).",
|
|
122
|
+
hint="The requested Java version may not exist for your platform.",
|
|
123
|
+
)
|
|
124
|
+
if response.status_code >= 400:
|
|
125
|
+
raise ApiError(
|
|
126
|
+
f"Adoptium API returned HTTP {response.status_code} for {path}."
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
return response.json()
|
|
130
|
+
except ValueError as exc:
|
|
131
|
+
raise ApiError("Adoptium API returned invalid JSON.") from exc
|
|
132
|
+
|
|
133
|
+
# --------------------------------------------------------------- metadata
|
|
134
|
+
def available_releases(self) -> dict[str, Any]:
|
|
135
|
+
data = self._get("info/available_releases")
|
|
136
|
+
if not isinstance(data, dict):
|
|
137
|
+
raise ApiError("Adoptium API returned an unexpected release list.")
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
def releases(self) -> list[int]:
|
|
141
|
+
data = self.available_releases()
|
|
142
|
+
raw = data.get("available_releases") or []
|
|
143
|
+
releases = sorted(
|
|
144
|
+
{int(value) for value in raw if _as_int(value) is not None}
|
|
145
|
+
)
|
|
146
|
+
if not releases:
|
|
147
|
+
raise ApiError("Adoptium API returned no available Java releases.")
|
|
148
|
+
return releases
|
|
149
|
+
|
|
150
|
+
def lts_releases(self) -> list[int]:
|
|
151
|
+
data = self.available_releases()
|
|
152
|
+
raw = data.get("available_lts_releases") or []
|
|
153
|
+
return sorted({int(value) for value in raw if _as_int(value) is not None})
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------- packages
|
|
156
|
+
def latest_package(
|
|
157
|
+
self,
|
|
158
|
+
major: int,
|
|
159
|
+
*,
|
|
160
|
+
os_name: str | None = None,
|
|
161
|
+
arch: str | None = None,
|
|
162
|
+
) -> JavaPackage:
|
|
163
|
+
os_name = (os_name or current_os()).lower()
|
|
164
|
+
arch = (arch or current_arch()).lower()
|
|
165
|
+
params = {
|
|
166
|
+
"architecture": arch,
|
|
167
|
+
"image_type": "jdk",
|
|
168
|
+
"os": os_name,
|
|
169
|
+
"vendor": "eclipse",
|
|
170
|
+
"project": "jdk",
|
|
171
|
+
}
|
|
172
|
+
data = self._get(f"assets/latest/{int(major)}/hotspot", params=params)
|
|
173
|
+
assets = data if isinstance(data, list) else data.get("assets", [])
|
|
174
|
+
if not isinstance(assets, list) or not assets:
|
|
175
|
+
raise ApiError(
|
|
176
|
+
f"No Temurin JDK {major} package found for {os_name}/{arch}.",
|
|
177
|
+
hint="Run `slime java releases` to see supported versions, or install Java manually.",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
for asset in assets:
|
|
181
|
+
if not isinstance(asset, dict):
|
|
182
|
+
continue
|
|
183
|
+
binary = asset.get("binary") or {}
|
|
184
|
+
package = binary.get("package") or {}
|
|
185
|
+
link = package.get("link")
|
|
186
|
+
if not link:
|
|
187
|
+
continue
|
|
188
|
+
checksum = package.get("checksum")
|
|
189
|
+
if isinstance(checksum, dict):
|
|
190
|
+
checksum = checksum.get("checksum") or checksum.get("value")
|
|
191
|
+
version = (
|
|
192
|
+
(asset.get("version") or {}).get("semver")
|
|
193
|
+
or (asset.get("release") or {}).get("version", {}).get("semver")
|
|
194
|
+
or (asset.get("version") or {}).get("openjdk_version")
|
|
195
|
+
or str(major)
|
|
196
|
+
)
|
|
197
|
+
return JavaPackage(
|
|
198
|
+
major=int(major),
|
|
199
|
+
version=str(version),
|
|
200
|
+
os=str(binary.get("os") or os_name),
|
|
201
|
+
arch=str(binary.get("architecture") or arch),
|
|
202
|
+
name=str(package.get("name") or f"temurin-{major}.zip"),
|
|
203
|
+
url=str(link),
|
|
204
|
+
sha256=str(checksum) if checksum else None,
|
|
205
|
+
size=_as_int(package.get("size")),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
raise ApiError(
|
|
209
|
+
f"Adoptium returned assets for Java {major}, but none had an archive package.",
|
|
210
|
+
hint="Try again later or install Java manually.",
|
|
211
|
+
)
|
slime/api/papermc.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Client for the PaperMC Fill API v3.
|
|
2
|
+
|
|
3
|
+
Fill is the official download API for Paper, Folia, Velocity and Waterfall.
|
|
4
|
+
Documentation: https://docs.papermc.io/misc/download-api/
|
|
5
|
+
|
|
6
|
+
The parser is deliberately tolerant: Fill has changed small details between
|
|
7
|
+
v2 and v3, and self-hosted mirrors may wrap payloads slightly differently.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .. import __version__
|
|
18
|
+
from ..errors import ApiError
|
|
19
|
+
from ..models import BuildInfo, DownloadInfo, sort_versions
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://fill.papermc.io/v3"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_int(value: Any) -> int | None:
|
|
25
|
+
try:
|
|
26
|
+
return int(value)
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _project_id(value: Any) -> str | None:
|
|
32
|
+
"""Extract a project id from a Fill project object or plain string."""
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
return value or None
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
nested = value.get("project")
|
|
37
|
+
if isinstance(nested, dict):
|
|
38
|
+
value = nested
|
|
39
|
+
project_id = value.get("id") or value.get("project")
|
|
40
|
+
return str(project_id) if project_id else None
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _version_id(value: Any) -> str | None:
|
|
45
|
+
"""Extract a Minecraft version id from any Fill v3 version shape."""
|
|
46
|
+
if isinstance(value, str):
|
|
47
|
+
return value or None
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
nested = value.get("version")
|
|
50
|
+
if isinstance(nested, dict):
|
|
51
|
+
value = nested
|
|
52
|
+
elif isinstance(nested, str):
|
|
53
|
+
return nested or None
|
|
54
|
+
version_id = value.get("id") or value.get("version")
|
|
55
|
+
return str(version_id) if version_id else None
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PaperMCClient:
|
|
60
|
+
"""Thin synchronous client around the Fill API."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
65
|
+
*,
|
|
66
|
+
user_agent: str = f"Slime/{__version__}",
|
|
67
|
+
timeout: float = 30.0,
|
|
68
|
+
client: httpx.Client | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.base_url = base_url.rstrip("/")
|
|
71
|
+
self.user_agent = user_agent
|
|
72
|
+
self._client = client or httpx.Client(
|
|
73
|
+
follow_redirects=True,
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
headers={"User-Agent": user_agent, "Accept": "application/json"},
|
|
76
|
+
)
|
|
77
|
+
self._owns_client = client is None
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------ http
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
if self._owns_client:
|
|
82
|
+
self._client.close()
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> PaperMCClient:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *_: object) -> None:
|
|
88
|
+
self.close()
|
|
89
|
+
|
|
90
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
91
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
92
|
+
try:
|
|
93
|
+
response = self._client.get(url, params=params)
|
|
94
|
+
except httpx.HTTPError as exc:
|
|
95
|
+
raise ApiError(
|
|
96
|
+
f"Could not reach the PaperMC API: {exc}",
|
|
97
|
+
hint="Check your internet connection or try again in a minute.",
|
|
98
|
+
) from exc
|
|
99
|
+
if response.status_code == 404:
|
|
100
|
+
raise ApiError(
|
|
101
|
+
f"PaperMC API has no data for {path}.",
|
|
102
|
+
hint="The project or Minecraft version may not exist yet.",
|
|
103
|
+
)
|
|
104
|
+
if response.status_code == 403:
|
|
105
|
+
raise ApiError(
|
|
106
|
+
"PaperMC API rejected the request (HTTP 403).",
|
|
107
|
+
hint="Fill requires a descriptive User-Agent; update Slime or set one in config.toml.",
|
|
108
|
+
)
|
|
109
|
+
if response.status_code >= 400:
|
|
110
|
+
raise ApiError(f"PaperMC API returned HTTP {response.status_code} for {path}.")
|
|
111
|
+
try:
|
|
112
|
+
return response.json()
|
|
113
|
+
except ValueError as exc:
|
|
114
|
+
raise ApiError("PaperMC API returned invalid JSON.") from exc
|
|
115
|
+
|
|
116
|
+
# -------------------------------------------------------------- projects
|
|
117
|
+
def projects(self) -> dict[str, str]:
|
|
118
|
+
data = self._get("projects")
|
|
119
|
+
raw = data.get("projects", data) if isinstance(data, dict) else data
|
|
120
|
+
result: dict[str, str] = {}
|
|
121
|
+
if isinstance(raw, dict):
|
|
122
|
+
for key, value in raw.items():
|
|
123
|
+
if isinstance(value, dict):
|
|
124
|
+
nested = value.get("project")
|
|
125
|
+
if isinstance(nested, dict):
|
|
126
|
+
value = nested
|
|
127
|
+
project_id = str(value.get("id") or key)
|
|
128
|
+
result[project_id] = str(value.get("name") or project_id)
|
|
129
|
+
else:
|
|
130
|
+
result[str(key)] = str(value)
|
|
131
|
+
elif isinstance(raw, list):
|
|
132
|
+
for item in raw:
|
|
133
|
+
item_project_id = _project_id(item)
|
|
134
|
+
if not item_project_id:
|
|
135
|
+
continue
|
|
136
|
+
name = item_project_id
|
|
137
|
+
if isinstance(item, dict):
|
|
138
|
+
nested = item.get("project")
|
|
139
|
+
project = nested if isinstance(nested, dict) else item
|
|
140
|
+
name = str(project.get("name") or item_project_id)
|
|
141
|
+
result[item_project_id] = name
|
|
142
|
+
if not result:
|
|
143
|
+
raise ApiError("PaperMC API returned an empty project list.")
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
# -------------------------------------------------------------- versions
|
|
147
|
+
def version_groups(self, project: str) -> dict[str, list[str]]:
|
|
148
|
+
data = self._get(f"projects/{quote(project, safe='')}/versions")
|
|
149
|
+
raw = data.get("versions", data) if isinstance(data, dict) else data
|
|
150
|
+
groups: dict[str, list[str]] = {}
|
|
151
|
+
if isinstance(raw, dict):
|
|
152
|
+
for group, versions in raw.items():
|
|
153
|
+
if isinstance(versions, list):
|
|
154
|
+
parsed = [version for version in map(_version_id, versions) if version]
|
|
155
|
+
if parsed:
|
|
156
|
+
groups[str(group)] = parsed
|
|
157
|
+
elif isinstance(raw, list):
|
|
158
|
+
for item in raw:
|
|
159
|
+
version = _version_id(item)
|
|
160
|
+
if version:
|
|
161
|
+
groups.setdefault("all", []).append(version)
|
|
162
|
+
if not groups:
|
|
163
|
+
raise ApiError(f"PaperMC API returned no versions for project '{project}'.")
|
|
164
|
+
return groups
|
|
165
|
+
|
|
166
|
+
def versions(self, project: str) -> list[str]:
|
|
167
|
+
groups = self.version_groups(project)
|
|
168
|
+
flattened = {version for versions in groups.values() for version in versions}
|
|
169
|
+
return sort_versions(list(flattened))
|
|
170
|
+
|
|
171
|
+
def latest_version(self, project: str) -> str:
|
|
172
|
+
versions = self.versions(project)
|
|
173
|
+
if not versions:
|
|
174
|
+
raise ApiError(f"No versions are available for project '{project}'.")
|
|
175
|
+
return versions[0]
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------- builds
|
|
178
|
+
def builds(self, project: str, version: str) -> list[BuildInfo]:
|
|
179
|
+
project_part = quote(project, safe="")
|
|
180
|
+
version_part = quote(version, safe="")
|
|
181
|
+
data = self._get(f"projects/{project_part}/versions/{version_part}/builds")
|
|
182
|
+
raw = data.get("builds", data) if isinstance(data, dict) else data
|
|
183
|
+
if not isinstance(raw, list):
|
|
184
|
+
raise ApiError(
|
|
185
|
+
f"Unexpected build list for {project} {version}: expected a JSON array."
|
|
186
|
+
)
|
|
187
|
+
builds: list[BuildInfo] = []
|
|
188
|
+
for item in raw:
|
|
189
|
+
if isinstance(item, dict):
|
|
190
|
+
builds.append(self._parse_build(project, version, item))
|
|
191
|
+
if not builds:
|
|
192
|
+
raise ApiError(
|
|
193
|
+
f"No builds found for {project} {version}.",
|
|
194
|
+
hint="Try another version: slime server versions --project " + project,
|
|
195
|
+
)
|
|
196
|
+
builds.sort(key=lambda build: build.build)
|
|
197
|
+
return builds
|
|
198
|
+
|
|
199
|
+
def build(self, project: str, version: str, build: int) -> BuildInfo:
|
|
200
|
+
project_part = quote(project, safe="")
|
|
201
|
+
version_part = quote(version, safe="")
|
|
202
|
+
data = self._get(
|
|
203
|
+
f"projects/{project_part}/versions/{version_part}/builds/{int(build)}"
|
|
204
|
+
)
|
|
205
|
+
raw = data.get("build", data) if isinstance(data, dict) else data
|
|
206
|
+
if not isinstance(raw, dict):
|
|
207
|
+
raise ApiError(f"Unexpected response for {project} {version} build {build}.")
|
|
208
|
+
return self._parse_build(project, version, raw)
|
|
209
|
+
|
|
210
|
+
def latest_build(
|
|
211
|
+
self,
|
|
212
|
+
project: str,
|
|
213
|
+
version: str,
|
|
214
|
+
*,
|
|
215
|
+
channel: str = "STABLE",
|
|
216
|
+
) -> BuildInfo:
|
|
217
|
+
builds = self.builds(project, version)
|
|
218
|
+
requested = channel.strip().upper()
|
|
219
|
+
if requested and requested != "ANY":
|
|
220
|
+
matching = [build for build in builds if build.channel.upper() == requested]
|
|
221
|
+
if matching:
|
|
222
|
+
return max(matching, key=lambda build: build.build)
|
|
223
|
+
return max(builds, key=lambda build: build.build)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _parse_build(project: str, version: str, item: dict[str, Any]) -> BuildInfo:
|
|
227
|
+
build_id = _as_int(item.get("id", item.get("build")))
|
|
228
|
+
if build_id is None:
|
|
229
|
+
raise ApiError(f"Build for {project} {version} is missing an id.")
|
|
230
|
+
|
|
231
|
+
downloads: dict[str, DownloadInfo] = {}
|
|
232
|
+
raw_downloads = item.get("downloads") or {}
|
|
233
|
+
if isinstance(raw_downloads, dict):
|
|
234
|
+
for key, payload in raw_downloads.items():
|
|
235
|
+
if not isinstance(payload, dict) or not payload.get("url"):
|
|
236
|
+
continue
|
|
237
|
+
checksums = payload.get("checksums") or {}
|
|
238
|
+
sha256 = checksums.get("sha256") if isinstance(checksums, dict) else None
|
|
239
|
+
downloads[str(key)] = DownloadInfo(
|
|
240
|
+
name=str(
|
|
241
|
+
payload.get("name")
|
|
242
|
+
or f"{project}-{version}-{build_id}.jar"
|
|
243
|
+
),
|
|
244
|
+
url=str(payload["url"]),
|
|
245
|
+
sha256=str(sha256) if sha256 else None,
|
|
246
|
+
size=_as_int(payload.get("size")),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return BuildInfo(
|
|
250
|
+
project=str(item.get("project") or project),
|
|
251
|
+
version=str(item.get("version") or version),
|
|
252
|
+
build=build_id,
|
|
253
|
+
channel=str(item.get("channel") or "STABLE").upper(),
|
|
254
|
+
time=str(item["time"]) if item.get("time") else None,
|
|
255
|
+
downloads=downloads,
|
|
256
|
+
)
|