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 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
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m slime``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
+ )