bsm-api-client 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.
@@ -0,0 +1,252 @@
1
+ # src/bsm_api_client/client/_scheduler_methods.py
2
+ """Mixin class containing OS-specific task scheduler methods."""
3
+ import logging
4
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
5
+ from urllib.parse import quote # For URL encoding path parameters
6
+
7
+ if TYPE_CHECKING:
8
+ from ..client_base import ClientBase # For type hinting _request
9
+
10
+ _LOGGER = logging.getLogger(__name__.split(".")[0] + ".client.scheduler")
11
+
12
+ # Define allowed commands for Windows tasks for client-side validation
13
+ ALLOWED_WINDOWS_TASK_COMMANDS = [
14
+ "update-server",
15
+ "backup-all",
16
+ "start-server",
17
+ "stop-server",
18
+ "restart-server",
19
+ "scan-players",
20
+ ]
21
+
22
+
23
+ class SchedulerMethodsMixin:
24
+ """Mixin for OS-specific task scheduler endpoints."""
25
+
26
+ _request: callable
27
+ if TYPE_CHECKING:
28
+
29
+ async def _request(
30
+ self: "ClientBase",
31
+ method: str,
32
+ path: str,
33
+ json_data: Optional[Dict[str, Any]] = None,
34
+ params: Optional[Dict[str, Any]] = None,
35
+ authenticated: bool = True,
36
+ is_retry: bool = False,
37
+ ) -> Any: ...
38
+
39
+ async def async_add_server_cron_job(
40
+ self, server_name: str, new_cron_job: str
41
+ ) -> Dict[str, Any]:
42
+ """
43
+ Adds a new cron job to the crontab of the user running the manager.
44
+ **Linux Only.**
45
+
46
+ Corresponds to `POST /api/server/{server_name}/cron_scheduler/add`.
47
+ Requires authentication.
48
+
49
+ Args:
50
+ server_name: The server context for the request.
51
+ new_cron_job: The complete cron job line string to add.
52
+ """
53
+ _LOGGER.info("Adding cron job for server '%s': '%s'", server_name, new_cron_job)
54
+ payload = {"new_cron_job": new_cron_job}
55
+
56
+ return await self._request(
57
+ "POST",
58
+ f"/server/{server_name}/cron_scheduler/add",
59
+ json_data=payload,
60
+ authenticated=True,
61
+ )
62
+
63
+ async def async_modify_server_cron_job(
64
+ self, server_name: str, old_cron_job: str, new_cron_job: str
65
+ ) -> Dict[str, Any]:
66
+ """
67
+ Modifies an existing cron job by exact match.
68
+ **Linux Only.**
69
+
70
+ Corresponds to `POST /api/server/{server_name}/cron_scheduler/modify`.
71
+ Requires authentication.
72
+
73
+ Args:
74
+ server_name: The server context.
75
+ old_cron_job: The exact existing cron job line to replace.
76
+ new_cron_job: The new cron job line.
77
+ """
78
+ _LOGGER.info(
79
+ "Modifying cron job for server '%s'. Old: '%s', New: '%s'",
80
+ server_name,
81
+ old_cron_job,
82
+ new_cron_job,
83
+ )
84
+ payload = {"old_cron_job": old_cron_job, "new_cron_job": new_cron_job}
85
+
86
+ return await self._request(
87
+ "POST",
88
+ f"/server/{server_name}/cron_scheduler/modify",
89
+ json_data=payload,
90
+ authenticated=True,
91
+ )
92
+
93
+ async def async_delete_server_cron_job(
94
+ self, server_name: str, cron_string: str
95
+ ) -> Dict[str, Any]:
96
+ """
97
+ Deletes a cron job by exact string match.
98
+ **Linux Only.** The `cron_string` will be URL-encoded by the HTTP client.
99
+
100
+ Corresponds to `DELETE /api/server/{server_name}/cron_scheduler/delete`.
101
+ Requires authentication.
102
+
103
+ Args:
104
+ server_name: The server context.
105
+ cron_string: The exact cron job line to delete.
106
+ """
107
+ _LOGGER.info(
108
+ "Deleting cron job for server '%s': '%s'", server_name, cron_string
109
+ )
110
+
111
+ return await self._request(
112
+ "DELETE",
113
+ f"/server/{server_name}/cron_scheduler/delete",
114
+ params={"cron_string": cron_string}, # aiohttp handles query param encoding
115
+ authenticated=True,
116
+ )
117
+
118
+ async def async_add_server_windows_task(
119
+ self, server_name: str, command: str, triggers: List[Dict[str, Any]]
120
+ ) -> Dict[str, Any]:
121
+ """
122
+ Adds a new scheduled task in Windows Task Scheduler.
123
+ **Windows Only.**
124
+
125
+ Corresponds to `POST /api/server/{server_name}/task_scheduler/add`.
126
+ Requires authentication.
127
+
128
+ Args:
129
+ server_name: The server context for the task.
130
+ command: The manager command to execute (e.g., "backup-all").
131
+ triggers: A list of trigger definition objects. See API docs for structure.
132
+ """
133
+ if command not in ALLOWED_WINDOWS_TASK_COMMANDS:
134
+ _LOGGER.error(
135
+ "Invalid command '%s' for Windows task. Allowed: %s",
136
+ command,
137
+ ALLOWED_WINDOWS_TASK_COMMANDS,
138
+ )
139
+ raise ValueError(
140
+ f"Invalid command '{command}' provided. Allowed commands are: {', '.join(ALLOWED_WINDOWS_TASK_COMMANDS)}"
141
+ )
142
+
143
+ _LOGGER.info(
144
+ "Adding Windows task for server '%s', command: '%s'", server_name, command
145
+ )
146
+ payload = {"command": command, "triggers": triggers}
147
+
148
+ return await self._request(
149
+ "POST",
150
+ f"/server/{server_name}/task_scheduler/add",
151
+ json_data=payload,
152
+ authenticated=True,
153
+ )
154
+
155
+ async def async_get_server_windows_task_details(
156
+ self, server_name: str, task_name: str
157
+ ) -> Dict[str, Any]:
158
+ """
159
+ Retrieves details of a specific Windows scheduled task.
160
+ **Windows Only.**
161
+
162
+ Corresponds to `POST /api/server/{server_name}/task_scheduler/details`.
163
+ Requires authentication.
164
+
165
+ Args:
166
+ server_name: The server context.
167
+ task_name: The full name of the task.
168
+ """
169
+ _LOGGER.info(
170
+ "Getting Windows task details for server '%s', task: '%s'",
171
+ server_name,
172
+ task_name,
173
+ )
174
+ payload = {"task_name": task_name}
175
+
176
+ return await self._request(
177
+ "POST",
178
+ f"/server/{server_name}/task_scheduler/details",
179
+ json_data=payload,
180
+ authenticated=True,
181
+ )
182
+
183
+ async def async_modify_server_windows_task(
184
+ self,
185
+ server_name: str,
186
+ task_name: str,
187
+ command: str,
188
+ triggers: List[Dict[str, Any]],
189
+ ) -> Dict[str, Any]:
190
+ """
191
+ Modifies an existing Windows scheduled task by replacing it.
192
+ **Windows Only.** The `task_name` in the path will be URL-encoded.
193
+
194
+ Corresponds to `PUT /api/server/{server_name}/task_scheduler/task/{task_name}`.
195
+ Requires authentication.
196
+
197
+ Args:
198
+ server_name: The server context.
199
+ task_name: The current full name of the task to replace.
200
+ command: The new manager command for the task.
201
+ triggers: A list of new trigger definitions for the task.
202
+ """
203
+ if command not in ALLOWED_WINDOWS_TASK_COMMANDS:
204
+ _LOGGER.error(
205
+ "Invalid command '%s' for Windows task modification. Allowed: %s",
206
+ command,
207
+ ALLOWED_WINDOWS_TASK_COMMANDS,
208
+ )
209
+ raise ValueError(
210
+ f"Invalid command '{command}' provided. Allowed commands are: {', '.join(ALLOWED_WINDOWS_TASK_COMMANDS)}"
211
+ )
212
+
213
+ _LOGGER.info(
214
+ "Modifying Windows task '%s' for server '%s', new command: '%s'",
215
+ task_name,
216
+ server_name,
217
+ command,
218
+ )
219
+ payload = {"command": command, "triggers": triggers}
220
+ encoded_task_name = quote(task_name) # Basic URL encoding for path segment
221
+
222
+ return await self._request(
223
+ "PUT",
224
+ f"/server/{server_name}/task_scheduler/task/{encoded_task_name}",
225
+ json_data=payload,
226
+ authenticated=True,
227
+ )
228
+
229
+ async def async_delete_server_windows_task(
230
+ self, server_name: str, task_name: str
231
+ ) -> Dict[str, Any]:
232
+ """
233
+ Deletes an existing Windows scheduled task.
234
+ **Windows Only.** The `task_name` in the path will be URL-encoded.
235
+
236
+ Corresponds to `DELETE /api/server/{server_name}/task_scheduler/task/{task_name}`.
237
+ Requires authentication.
238
+
239
+ Args:
240
+ server_name: The server context.
241
+ task_name: The full name of the task to delete.
242
+ """
243
+ _LOGGER.info(
244
+ "Deleting Windows task '%s' for server '%s'", task_name, server_name
245
+ )
246
+ encoded_task_name = quote(task_name) # Basic URL encoding for path segment
247
+
248
+ return await self._request(
249
+ "DELETE",
250
+ f"/server/{server_name}/task_scheduler/task/{encoded_task_name}",
251
+ authenticated=True,
252
+ )
@@ -0,0 +1,352 @@
1
+ # src/bsm_api_client/client/_server_action_methods.py
2
+ """Mixin class containing server action methods."""
3
+ import logging
4
+ from typing import Any, Dict, Optional, List, TYPE_CHECKING
5
+ from urllib.parse import quote # For URL encoding path parameters
6
+
7
+ if TYPE_CHECKING:
8
+ from ..client_base import ClientBase # For type hinting _request
9
+
10
+ _LOGGER = logging.getLogger(__name__.split(".")[0] + ".client.server_actions")
11
+
12
+ ALLOWED_PERMISSION_LEVELS = ["visitor", "member", "operator"]
13
+ ALLOWED_SERVER_PROPERTIES_TO_UPDATE = [
14
+ "server-name",
15
+ "level-name",
16
+ "gamemode",
17
+ "difficulty",
18
+ "allow-cheats",
19
+ "max-players",
20
+ "server-port",
21
+ "server-portv6",
22
+ "enable-lan-visibility",
23
+ "allow-list",
24
+ "default-player-permission-level",
25
+ "view-distance",
26
+ "tick-distance",
27
+ "level-seed",
28
+ "online-mode",
29
+ "texturepack-required",
30
+ ]
31
+
32
+
33
+ class ServerActionMethodsMixin:
34
+ """Mixin for server action endpoints."""
35
+
36
+ _request: callable
37
+ if TYPE_CHECKING:
38
+
39
+ def is_linux_server(self: "ClientBase") -> bool: ...
40
+ def is_windows_server(self: "ClientBase") -> bool: ...
41
+
42
+ async def _request(
43
+ self: "ClientBase",
44
+ method: str,
45
+ path: str,
46
+ json_data: Optional[Dict[str, Any]] = None,
47
+ params: Optional[Dict[str, Any]] = None,
48
+ authenticated: bool = True,
49
+ is_retry: bool = False,
50
+ ) -> Any: ...
51
+
52
+ async def async_start_server(self, server_name: str) -> Dict[str, Any]:
53
+ """
54
+ Starts the specified Bedrock server instance.
55
+
56
+ Corresponds to `POST /api/server/{server_name}/start`.
57
+ Requires authentication.
58
+
59
+ Args:
60
+ server_name: The unique name of the server instance to start.
61
+ """
62
+ _LOGGER.info("Requesting start for server '%s'", server_name)
63
+ return await self._request(
64
+ "POST",
65
+ f"/server/{server_name}/start",
66
+ authenticated=True,
67
+ )
68
+
69
+ async def async_stop_server(self, server_name: str) -> Dict[str, Any]:
70
+ """
71
+ Stops the specified running Bedrock server instance.
72
+
73
+ Corresponds to `POST /api/server/{server_name}/stop`.
74
+ Requires authentication.
75
+
76
+ Args:
77
+ server_name: The unique name of the server instance to stop.
78
+ """
79
+ _LOGGER.info("Requesting stop for server '%s'", server_name)
80
+ return await self._request(
81
+ "POST",
82
+ f"/server/{server_name}/stop",
83
+ authenticated=True,
84
+ )
85
+
86
+ async def async_restart_server(self, server_name: str) -> Dict[str, Any]:
87
+ """
88
+ Restarts the specified Bedrock server instance.
89
+
90
+ Corresponds to `POST /api/server/{server_name}/restart`.
91
+ Requires authentication.
92
+
93
+ Args:
94
+ server_name: The unique name of the server instance to restart.
95
+ """
96
+ _LOGGER.info("Requesting restart for server '%s'", server_name)
97
+ return await self._request(
98
+ "POST",
99
+ f"/server/{server_name}/restart",
100
+ authenticated=True,
101
+ )
102
+
103
+ async def async_send_server_command(
104
+ self, server_name: str, command: str
105
+ ) -> Dict[str, Any]:
106
+ """
107
+ Sends a command string to the specified server's console.
108
+
109
+ Corresponds to `POST /api/server/{server_name}/send_command`.
110
+ Requires authentication.
111
+
112
+ Args:
113
+ server_name: The unique name of the target server instance.
114
+ command: The command string to send.
115
+ """
116
+ if not command or command.isspace():
117
+ raise ValueError("Command cannot be empty or just whitespace.")
118
+ _LOGGER.info("Sending command to server '%s': '%s'", server_name, command)
119
+ payload = {"command": command}
120
+
121
+ return await self._request(
122
+ "POST",
123
+ f"/server/{server_name}/send_command",
124
+ json_data=payload,
125
+ authenticated=True,
126
+ )
127
+
128
+ async def async_update_server(self, server_name: str) -> Dict[str, Any]:
129
+ """
130
+ Checks for and applies updates to the specified server instance.
131
+
132
+ Corresponds to `POST /api/server/{server_name}/update`.
133
+ Requires authentication.
134
+
135
+ Args:
136
+ server_name: The unique name of the server instance to update.
137
+ """
138
+ _LOGGER.info("Requesting update for server '%s'", server_name)
139
+ return await self._request(
140
+ "POST",
141
+ f"/server/{server_name}/update",
142
+ authenticated=True,
143
+ )
144
+
145
+ async def async_add_server_allowlist(
146
+ self, server_name: str, players: List[str], ignores_player_limit: bool = False
147
+ ) -> Dict[str, Any]:
148
+ """
149
+ Adds players to the server's allowlist.json file.
150
+
151
+ Corresponds to `POST /api/server/{server_name}/allowlist/add`.
152
+ Requires authentication.
153
+
154
+ Args:
155
+ server_name: The name of the server.
156
+ players: A list of player names (Gamertags) to add.
157
+ ignores_player_limit: Sets the 'ignoresPlayerLimit' flag for added players.
158
+ """
159
+ if not isinstance(players, list):
160
+ raise TypeError("Players must be a list of strings.")
161
+ if (
162
+ not all(isinstance(p, str) and p.strip() for p in players) and players
163
+ ): # Allow empty list, but not list with empty/invalid names
164
+ raise ValueError("All player names in the list must be non-empty strings.")
165
+
166
+ _LOGGER.info(
167
+ "Adding players %s to allowlist for server '%s' (ignores limit: %s)",
168
+ players,
169
+ server_name,
170
+ ignores_player_limit,
171
+ )
172
+ payload = {"players": players, "ignoresPlayerLimit": ignores_player_limit}
173
+
174
+ return await self._request(
175
+ "POST",
176
+ f"/server/{server_name}/allowlist/add",
177
+ json_data=payload,
178
+ authenticated=True,
179
+ )
180
+
181
+ async def async_remove_server_allowlist_player(
182
+ self, server_name: str, player_name: str
183
+ ) -> Dict[str, Any]:
184
+ """
185
+ Removes a specific player from the server's allowlist.json.
186
+ The player_name in the path will be URL-encoded.
187
+
188
+ Corresponds to `DELETE /api/server/{server_name}/allowlist/player/{player_name}`.
189
+ Requires authentication.
190
+
191
+ Args:
192
+ server_name: The name of the server.
193
+ player_name: The name of the player to remove (case-insensitive on API side).
194
+ """
195
+ if not player_name or player_name.isspace():
196
+ raise ValueError("Player name cannot be empty or just whitespace.")
197
+
198
+ _LOGGER.info(
199
+ "Removing player '%s' from allowlist for server '%s'",
200
+ player_name,
201
+ server_name,
202
+ )
203
+ encoded_player_name = quote(player_name)
204
+
205
+ return await self._request(
206
+ "DELETE",
207
+ f"/server/{server_name}/allowlist/player/{encoded_player_name}",
208
+ authenticated=True,
209
+ )
210
+
211
+ async def async_set_server_permissions(
212
+ self, server_name: str, permissions_dict: Dict[str, str]
213
+ ) -> Dict[str, Any]:
214
+ """
215
+ Updates permission levels for players in the server's permissions.json.
216
+
217
+ Corresponds to `PUT /api/server/{server_name}/permissions`.
218
+ Requires authentication.
219
+
220
+ Args:
221
+ server_name: The name of the server.
222
+ permissions_dict: A dictionary mapping player XUIDs (strings) to
223
+ permission levels ("visitor", "member", "operator").
224
+ """
225
+ if not isinstance(permissions_dict, dict):
226
+ raise TypeError("permissions_dict must be a dictionary.")
227
+
228
+ processed_permissions: Dict[str, str] = {}
229
+ for xuid, level in permissions_dict.items():
230
+ if (
231
+ not isinstance(level, str)
232
+ or level.lower() not in ALLOWED_PERMISSION_LEVELS
233
+ ):
234
+ _LOGGER.error(
235
+ "Invalid permission level '%s' for XUID '%s'. Allowed: %s",
236
+ level,
237
+ xuid,
238
+ ALLOWED_PERMISSION_LEVELS,
239
+ )
240
+ raise ValueError(
241
+ f"Invalid permission level '{level}' for XUID '{xuid}'. "
242
+ f"Allowed levels are: {', '.join(ALLOWED_PERMISSION_LEVELS)}"
243
+ )
244
+ processed_permissions[xuid] = level.lower() # API stores lowercase
245
+
246
+ _LOGGER.info(
247
+ "Setting permissions for server '%s': %s",
248
+ server_name,
249
+ processed_permissions,
250
+ )
251
+ payload = {"permissions": processed_permissions}
252
+
253
+ return await self._request(
254
+ "PUT",
255
+ f"/server/{server_name}/permissions",
256
+ json_data=payload,
257
+ authenticated=True,
258
+ )
259
+
260
+ async def async_update_server_properties(
261
+ self, server_name: str, properties_dict: Dict[str, Any]
262
+ ) -> Dict[str, Any]:
263
+ """
264
+ Updates specified key-value pairs in the server's server.properties file.
265
+ Only allowed properties will be modified by the API.
266
+
267
+ Corresponds to `POST /api/server/{server_name}/properties`.
268
+ Requires authentication.
269
+
270
+ Args:
271
+ server_name: The name of the server.
272
+ properties_dict: A dictionary of properties to update.
273
+ """
274
+ if not isinstance(properties_dict, dict):
275
+ raise TypeError("properties_dict must be a dictionary.")
276
+
277
+ for key_provided in properties_dict.keys():
278
+ if key_provided not in ALLOWED_SERVER_PROPERTIES_TO_UPDATE:
279
+ _LOGGER.warning(
280
+ "Property '%s' is not in the list of API-allowed modifiable properties and might be ignored by the API.",
281
+ key_provided,
282
+ )
283
+
284
+ _LOGGER.info(
285
+ "Updating properties for server '%s': %s", server_name, properties_dict
286
+ )
287
+ # The API expects the properties directly as the JSON body, not nested under a key.
288
+ payload = properties_dict
289
+
290
+ return await self._request(
291
+ "POST",
292
+ f"/server/{server_name}/properties",
293
+ json_data=payload,
294
+ authenticated=True,
295
+ )
296
+
297
+ async def async_configure_server_os_service(
298
+ self, server_name: str, service_config: Dict[str, bool]
299
+ ) -> Dict[str, Any]:
300
+ """
301
+ Configures OS-specific service settings (e.g., systemd, autoupdate flag).
302
+ The exact keys required in `service_config` depend on the server's OS.
303
+ Linux: {"autoupdate": bool, "autostart": bool}
304
+ Windows: {"autoupdate": bool}
305
+
306
+ Corresponds to `POST /api/server/{server_name}/service`.
307
+ Requires authentication.
308
+
309
+ Args:
310
+ server_name: The name of the server.
311
+ service_config: A dictionary with OS-specific boolean flags.
312
+ """
313
+ if not isinstance(service_config, dict):
314
+ raise TypeError("service_config must be a dictionary.")
315
+ for key, value in service_config.items():
316
+ if not isinstance(value, bool):
317
+ raise ValueError(
318
+ f"Value for service config key '{key}' must be a boolean."
319
+ )
320
+
321
+ _LOGGER.info(
322
+ "Requesting OS service config for server '%s' with payload: %s",
323
+ server_name,
324
+ service_config,
325
+ )
326
+
327
+ return await self._request(
328
+ "POST",
329
+ f"/server/{server_name}/service",
330
+ json_data=service_config,
331
+ authenticated=True,
332
+ )
333
+
334
+ async def async_delete_server(self, server_name: str) -> Dict[str, Any]:
335
+ """
336
+ Permanently deletes all data associated with the specified server instance.
337
+ **USE WITH EXTREME CAUTION: This action is irreversible.**
338
+
339
+ Corresponds to `DELETE /api/server/{server_name}/delete`.
340
+ Requires authentication.
341
+
342
+ Args:
343
+ server_name: The unique name of the server instance to delete.
344
+ """
345
+ _LOGGER.warning(
346
+ "Requesting DELETION of server '%s'. THIS IS IRREVERSIBLE.", server_name
347
+ )
348
+ return await self._request(
349
+ "DELETE",
350
+ f"/server/{server_name}/delete",
351
+ authenticated=True,
352
+ )