bsm-cli 1.6.0b3__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.
bsm_cli/bans.py ADDED
@@ -0,0 +1,190 @@
1
+ import click
2
+ import questionary
3
+
4
+ from bsm_api_client.models import BanAddRequest, BanRemoveRequest
5
+
6
+
7
+ @click.group()
8
+ def bans():
9
+ """Manages the server ban list."""
10
+ pass
11
+
12
+
13
+ @bans.command("list")
14
+ @click.option(
15
+ "-s",
16
+ "--server",
17
+ "server_name",
18
+ required=True,
19
+ help="Name of the server.",
20
+ )
21
+ @click.pass_context
22
+ async def list_bans(ctx, server_name: str):
23
+ """Lists all players on the server's ban list."""
24
+ client = ctx.obj.get("client")
25
+ if not client:
26
+ click.secho("You are not logged in.", fg="red")
27
+ return
28
+
29
+ click.echo(f"Fetching ban list for server '{server_name}'...")
30
+ response = await client.async_get_server_bans(server_name)
31
+
32
+ if response.get("status") == "success":
33
+ bans_list = response.get("bans", [])
34
+ if not bans_list:
35
+ click.secho("The ban list is empty.", fg="green")
36
+ else:
37
+ click.secho(f"\n--- Ban List for '{server_name}' ---", bold=True)
38
+ for entry in bans_list:
39
+ click.echo(
40
+ f"- {click.style(entry.get('player_name', 'Unknown'), fg='cyan')} (XUID: {entry.get('xuid')}) - Reason: {entry.get('reason', 'N/A')}"
41
+ )
42
+ else:
43
+ click.secho(
44
+ f"Failed to fetch ban list: {response.get('message', 'Unknown Error')}",
45
+ fg="red",
46
+ )
47
+
48
+
49
+ @bans.command("add")
50
+ @click.option(
51
+ "-s",
52
+ "--server",
53
+ "server_name",
54
+ required=True,
55
+ help="Name of the server.",
56
+ )
57
+ @click.pass_context
58
+ async def add_ban(ctx, server_name: str):
59
+ """Adds a player to the server ban list interactively."""
60
+ client = ctx.obj.get("client")
61
+ if not client:
62
+ click.secho("You are not logged in.", fg="red")
63
+ return
64
+
65
+ await interactive_ban_workflow(client, server_name)
66
+
67
+
68
+ @bans.command("remove")
69
+ @click.option(
70
+ "-s",
71
+ "--server",
72
+ "server_name",
73
+ required=True,
74
+ help="Name of the server.",
75
+ )
76
+ @click.pass_context
77
+ async def remove_ban(ctx, server_name: str):
78
+ """Removes a player from the server ban list interactively."""
79
+ client = ctx.obj.get("client")
80
+ if not client:
81
+ click.secho("You are not logged in.", fg="red")
82
+ return
83
+
84
+ response = await client.async_get_server_bans(server_name)
85
+ if response.get("status") != "success":
86
+ click.secho(
87
+ f"Failed to fetch ban list: {response.get('message', 'Unknown Error')}",
88
+ fg="red",
89
+ )
90
+ return
91
+
92
+ bans_list = response.get("bans", [])
93
+ if not bans_list:
94
+ click.secho("The ban list is currently empty.", fg="yellow")
95
+ return
96
+
97
+ choices = [
98
+ questionary.Choice(
99
+ title=f"{entry.get('player_name', 'Unknown')} ({entry.get('xuid')})",
100
+ value=entry.get("xuid"),
101
+ )
102
+ for entry in bans_list
103
+ ]
104
+
105
+ selected_xuid = await questionary.select(
106
+ "Select a player to remove from the ban list:", choices=choices
107
+ ).ask_async()
108
+
109
+ if not selected_xuid:
110
+ return
111
+
112
+ confirm = await questionary.confirm(
113
+ f"Are you sure you want to remove XUID '{selected_xuid}' from the ban list?"
114
+ ).ask_async()
115
+ if not confirm:
116
+ click.secho("Operation cancelled.", fg="yellow")
117
+ return
118
+
119
+ payload = BanRemoveRequest(xuid=selected_xuid)
120
+
121
+ click.echo(
122
+ f"Removing XUID '{selected_xuid}' from the ban list for '{server_name}'..."
123
+ )
124
+ rem_response = await client.async_remove_server_ban(server_name, payload)
125
+
126
+ if rem_response.get("status") == "success":
127
+ click.secho("Player successfully removed from the ban list.", fg="green")
128
+ else:
129
+ click.secho(
130
+ f"Failed to remove player from ban list: {rem_response.get('message', 'Unknown Error')}",
131
+ fg="red",
132
+ )
133
+
134
+
135
+ async def interactive_ban_workflow(client, server_name: str):
136
+ """Guides the user through an interactive session to view and add players to the ban list."""
137
+ response = await client.async_get_server_bans(server_name)
138
+ existing_bans = response.get("bans", [])
139
+
140
+ click.secho("\n--- Interactive Ban List Configuration ---", bold=True)
141
+ if existing_bans:
142
+ click.echo("Current players in ban list:")
143
+ for b in existing_bans:
144
+ click.echo(
145
+ f" - {b.get('player_name')} (XUID: {b.get('xuid')}) - Reason: {b.get('reason', 'N/A')}"
146
+ )
147
+ else:
148
+ click.secho("Ban list is currently empty.", fg="yellow")
149
+
150
+ click.echo("\nEnter a new player to ban. Leave Gamertag empty to finish.")
151
+ while True:
152
+ player_name = await questionary.text("Player gamertag:").ask_async()
153
+ if not player_name or not player_name.strip():
154
+ break
155
+
156
+ xuid = await questionary.text("Player XUID:").ask_async()
157
+ if not xuid or not xuid.strip():
158
+ click.secho("XUID is required to ban a player.", fg="red")
159
+ continue
160
+
161
+ reason = await questionary.text("Reason (optional):").ask_async()
162
+
163
+ if any(b.get("xuid") == xuid.strip() for b in existing_bans):
164
+ click.secho(
165
+ f"Player with XUID '{xuid}' is already in the list. Skipping.",
166
+ fg="yellow",
167
+ )
168
+ continue
169
+
170
+ payload = BanAddRequest(
171
+ player_name=player_name.strip(),
172
+ xuid=xuid.strip(),
173
+ reason=reason.strip() if reason.strip() else None,
174
+ )
175
+ res = await client.async_add_server_ban(server_name, payload)
176
+ if res.get("status") == "success":
177
+ click.secho(f"Successfully banned {player_name}.", fg="green")
178
+ existing_bans.append(
179
+ {
180
+ "player_name": player_name.strip(),
181
+ "xuid": xuid.strip(),
182
+ "reason": reason.strip() if reason.strip() else None,
183
+ }
184
+ )
185
+ else:
186
+ click.secho(
187
+ f"Failed to ban player: {res.get('message', 'Unknown Error')}", fg="red"
188
+ )
189
+
190
+ click.secho("Ban list interactive configuration finished.", fg="green")
bsm_cli/config.py ADDED
@@ -0,0 +1,89 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Any, Dict, Optional
5
+
6
+ CONFIG_FILE_NAME = ".bsm_cli_config.json"
7
+ DEFAULT_BASE_URL = "http://127.0.0.1:11325"
8
+
9
+
10
+ def get_config_path() -> Path:
11
+ """Gets the path to the configuration file."""
12
+ return Path.home() / CONFIG_FILE_NAME
13
+
14
+
15
+ def load_config() -> Dict[str, Any]:
16
+ """Loads the configuration from the config file."""
17
+ config_path = get_config_path()
18
+ if not config_path.exists():
19
+ return {}
20
+ with open(config_path, "r") as f:
21
+ data = json.load(f)
22
+ if isinstance(data, dict):
23
+ return data
24
+ return {}
25
+
26
+
27
+ def save_config(config: Dict[str, Any]):
28
+ """Saves the configuration to the config file."""
29
+ config_path = get_config_path()
30
+ with open(config_path, "w") as f:
31
+ json.dump(config, f, indent=4)
32
+
33
+
34
+ class Config:
35
+ """Manages CLI configuration."""
36
+
37
+ def __init__(self):
38
+ self._config = load_config()
39
+
40
+ def get(self, key: str, default: Any = None) -> Any:
41
+ """Gets a configuration value."""
42
+ # Prioritize environment variables
43
+ env_var = f"BSM_{key.upper()}"
44
+ value = os.environ.get(env_var)
45
+ if value:
46
+ if isinstance(default, bool):
47
+ return value.lower() in ("true", "1", "yes")
48
+ return value
49
+
50
+ # Fallback to config file
51
+ return self._config.get(key, default)
52
+
53
+ def set(self, key: str, value: Any):
54
+ """Sets a configuration value."""
55
+ self._config[key] = value
56
+ save_config(self._config)
57
+
58
+ @property
59
+ def base_url(self) -> str:
60
+ """The API base URL."""
61
+ return str(self.get("base_url", DEFAULT_BASE_URL))
62
+
63
+ @property
64
+ def verify_ssl(self) -> bool:
65
+ """Whether to verify SSL."""
66
+ return bool(self.get("verify_ssl", True))
67
+
68
+ @property
69
+ def username(self) -> Optional[str]:
70
+ """The username for authentication."""
71
+ val = self.get("username")
72
+ return str(val) if val is not None else None
73
+
74
+ @property
75
+ def password(self) -> Optional[str]:
76
+ """The password for authentication."""
77
+ val = self.get("password")
78
+ return str(val) if val is not None else None
79
+
80
+ @property
81
+ def jwt_token(self) -> Optional[str]:
82
+ """The JWT for authentication."""
83
+ val = self.get("jwt_token")
84
+ return str(val) if val is not None else None
85
+
86
+ @jwt_token.setter
87
+ def jwt_token(self, value: Optional[str]):
88
+ """Sets the JWT."""
89
+ self.set("jwt_token", value)
bsm_cli/content.py ADDED
@@ -0,0 +1,21 @@
1
+ # src/bsm_cli/content.py
2
+ """CLI commands for content management."""
3
+
4
+ import click
5
+ from bsm_cli.decorators import pass_async_context
6
+
7
+
8
+ @click.group()
9
+ def content():
10
+ """Commands for managing content."""
11
+ pass
12
+
13
+
14
+ @content.command()
15
+ @click.argument("file_path", type=click.Path(exists=True, dir_okay=False))
16
+ @pass_async_context
17
+ async def upload(ctx, file_path):
18
+ """Upload a content file."""
19
+ client = ctx.obj["client"]
20
+ response = await client.async_upload_content(file_path)
21
+ click.echo(response)
bsm_cli/decorators.py ADDED
@@ -0,0 +1,138 @@
1
+ import asyncio
2
+ import functools
3
+
4
+ import click
5
+
6
+ from bsm_api_client.exceptions import AuthError
7
+
8
+
9
+ class AsyncGroup(click.Group):
10
+ def __init__(self, *args, **kwargs):
11
+ super().__init__(*args, **kwargs)
12
+ self.async_context_settings = {}
13
+
14
+ def context(self, f):
15
+ self.async_context_settings["context"] = f
16
+ return f
17
+
18
+ def invoke(self, ctx):
19
+ ctx.obj = ctx.obj or {}
20
+ if self.async_context_settings.get("context"):
21
+
22
+ async def runner():
23
+ async with self.async_context_settings["context"](ctx):
24
+ result = super(AsyncGroup, self).invoke(ctx)
25
+ if asyncio.iscoroutine(result):
26
+ await result
27
+
28
+ return asyncio.run(runner())
29
+
30
+ result = super().invoke(ctx)
31
+ if asyncio.iscoroutine(result):
32
+ return asyncio.run(result)
33
+ return result
34
+
35
+
36
+ def pass_async_context(f):
37
+ @functools.wraps(f)
38
+ def wrapper(*args, **kwargs):
39
+ ctx = click.get_current_context()
40
+ return f(ctx, *args, **kwargs)
41
+
42
+ return wrapper
43
+
44
+
45
+ async def monitor_task( # noqa: C901
46
+ client, task_id: str, success_message: str, failure_message: str
47
+ ):
48
+ """Polls the status of a background task until it completes."""
49
+ click.echo("Task started in the background. Monitoring for completion...")
50
+
51
+ # Try WebSocket first
52
+ try:
53
+ ws_client = await client.websocket_connect()
54
+ async with ws_client:
55
+ # No subscription needed for task updates as per documentation
56
+ async for msg in ws_client.listen():
57
+ # Expected format: {"type": "task_update", "topic": "task:{task_id}", "data": {...}}
58
+ if (
59
+ msg.get("topic") == f"task:{task_id}"
60
+ and msg.get("type") == "task_update"
61
+ ):
62
+ data = msg.get("data", {})
63
+ status = data.get("status")
64
+ message = data.get("message", "No message provided.")
65
+
66
+ if status == "success":
67
+ click.secho(f"{success_message}: {message}", fg="green")
68
+ return
69
+ elif status == "error":
70
+ click.secho(f"{failure_message}: {message}", fg="red")
71
+ return
72
+ elif status == "in_progress":
73
+ # Maybe print progress if available, or just wait
74
+ pass
75
+ except AuthError:
76
+ click.secho(
77
+ "WebSocket authentication failed. Attempting to refresh token...",
78
+ fg="yellow",
79
+ )
80
+ try:
81
+ await client.authenticate()
82
+ # Retry WebSocket once
83
+ ws_client = await client.websocket_connect()
84
+ async with ws_client:
85
+ async for msg in ws_client.listen():
86
+ if (
87
+ msg.get("topic") == f"task:{task_id}"
88
+ and msg.get("type") == "task_update"
89
+ ):
90
+ data = msg.get("data", {})
91
+ status = data.get("status")
92
+ message = data.get("message", "No message provided.")
93
+
94
+ if status == "success":
95
+ click.secho(f"{success_message}: {message}", fg="green")
96
+ return
97
+ elif status == "error":
98
+ click.secho(f"{failure_message}: {message}", fg="red")
99
+ return
100
+ elif status == "in_progress":
101
+ pass
102
+ except Exception as e:
103
+ click.secho(
104
+ f"WebSocket retry failed ({e}), falling back to polling...", fg="yellow"
105
+ )
106
+ except Exception as e:
107
+ click.secho(
108
+ f"WebSocket monitoring failed ({e}), falling back to polling...",
109
+ fg="yellow",
110
+ )
111
+
112
+ # Fallback to polling
113
+ while True:
114
+ try:
115
+ status_response = await client.async_get_task_status(task_id)
116
+ status = status_response.get("status")
117
+ message = status_response.get("message", "No message provided.")
118
+
119
+ if status == "success":
120
+ click.secho(f"{success_message}: {message}", fg="green")
121
+ break
122
+ elif status == "error":
123
+ click.secho(
124
+ f"{failure_message}: {message}",
125
+ fg="red",
126
+ )
127
+ break
128
+ elif status == "pending" or status == "in_progress":
129
+ # Still waiting, continue loop
130
+ pass
131
+ else:
132
+ # Handle unexpected status
133
+ click.secho(f"Unknown task status received: {status}", fg="yellow")
134
+
135
+ await asyncio.sleep(2)
136
+ except Exception as e:
137
+ click.secho(f"An error occurred while monitoring task: {e}", fg="red")
138
+ await asyncio.sleep(2) # Retry polling on error