customrp-cli 0.1.1__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,3 @@
1
+ """customrp-cli: custom Discord Rich Presence from the command line, configured with a TOML file."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m customrp_cli``."""
2
+
3
+ import sys
4
+
5
+ from customrp_cli.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
customrp_cli/cli.py ADDED
@@ -0,0 +1,335 @@
1
+ """Command-line parsing and the commands other than ``run``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import sys
9
+ import time
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ from customrp_core import AssetsError, PresetError, build_activity, load_preset, save_preset
14
+ from customrp_core import __version__ as core_version
15
+ from customrp_core.assets import assets_page_url, fetch_assets
16
+
17
+ from . import __version__, logs, messages as msg, paths, runner
18
+ from .config import Config, ConfigError, load_config, write_config
19
+ from .instance import Instance
20
+
21
+ EXIT_OK = 0
22
+ EXIT_FAILED = 1
23
+ EXIT_CONFIG = 2 # Also what argparse uses for usage errors
24
+ EXIT_NOT_RUNNING = 3 # status only, like LSB init scripts
25
+
26
+ DESCRIPTION = """\
27
+ Show a custom Discord Rich Presence, configured with a TOML file.
28
+ An independent command-line alternative to CustomRP for Linux, macOS and Windows."""
29
+
30
+ EPILOG = """\
31
+ Start with "customrp-cli init", edit the config file it creates, then "customrp-cli run".
32
+ Documentation: https://github.com/KeiraOMG0/customrp-cli"""
33
+
34
+
35
+ def _out(text: str = ""):
36
+ print(text)
37
+
38
+
39
+ def _err(text: str):
40
+ print(text, file=sys.stderr)
41
+
42
+
43
+ def _common_options(parser: argparse.ArgumentParser, suppress: bool):
44
+ """-c/-v/-q work before and after the command; the copy on subcommands mustn't reset them."""
45
+ default = argparse.SUPPRESS if suppress else None
46
+ parser.add_argument("-c", "--config", type=Path, metavar="FILE", default=default,
47
+ help=f"config file (default: {paths.default_config_path()}; or set ${paths.CONFIG_ENV})")
48
+ parser.add_argument("-v", "--verbose", action="count", default=argparse.SUPPRESS if suppress else 0,
49
+ help="show debug output")
50
+ parser.add_argument("-q", "--quiet", action="store_true", default=argparse.SUPPRESS if suppress else False,
51
+ help="only show errors")
52
+
53
+
54
+ def build_parser() -> argparse.ArgumentParser:
55
+ parser = argparse.ArgumentParser(prog="customrp-cli", description=DESCRIPTION, epilog=EPILOG,
56
+ formatter_class=argparse.RawDescriptionHelpFormatter)
57
+ parser.add_argument("-V", "--version", action="version",
58
+ version=f"customrp-cli {__version__} (customrp-core {core_version})")
59
+ _common_options(parser, suppress=False)
60
+
61
+ common = argparse.ArgumentParser(add_help=False)
62
+ _common_options(common, suppress=True)
63
+
64
+ commands = parser.add_subparsers(dest="command", metavar="COMMAND")
65
+
66
+ def command(name, help_text, **kwargs):
67
+ return commands.add_parser(name, help=help_text, description=help_text, parents=[common], **kwargs)
68
+
69
+ p = command("init", "create a config file with every option and its default")
70
+ p.add_argument("--force", action="store_true", help="overwrite an existing file")
71
+
72
+ command("check", "check the config file for mistakes", aliases=["validate"])
73
+
74
+ command("preview", "print the activity that would be sent to Discord, as JSON")
75
+
76
+ p = command("run", "show the presence until stopped (Ctrl+C, SIGTERM or 'customrp-cli stop')")
77
+ p.add_argument("--no-watch", action="store_true", help="don't reload the config file when it changes")
78
+
79
+ p = command("stop", "stop the running instance")
80
+ p.add_argument("--timeout", type=float, default=10, metavar="SECONDS",
81
+ help="how long to wait for it to stop (default: 10)")
82
+
83
+ command("status", "say whether an instance is running, and how it's doing")
84
+
85
+ p = command("import", "replace the presence in the config file with a CustomRP preset (.crp)")
86
+ p.add_argument("preset", type=Path, help="the .crp file")
87
+ p.add_argument("--force", action="store_true", help="overwrite the existing config file's presence")
88
+
89
+ p = command("export", "save the configured presence as a CustomRP preset (.crp)")
90
+ p.add_argument("preset", type=Path, help="the .crp file to write")
91
+ p.add_argument("--force", action="store_true", help="overwrite an existing file")
92
+
93
+ p = command("assets", "list the art assets uploaded to the Discord application")
94
+ p.add_argument("--application-id", metavar="ID", help="application to look up (default: from the config file)")
95
+
96
+ command("paths", "show where the config and runtime files are")
97
+
98
+ return parser
99
+
100
+
101
+ # --- Helpers -------------------------------------------------------------------------
102
+
103
+ def _config_path(args) -> Path:
104
+ path = args.config or paths.default_config_path()
105
+ return path.expanduser().absolute()
106
+
107
+
108
+ def _load(args) -> Config | None:
109
+ """Loads the config, printing problems. Returns None if it can't be used."""
110
+ try:
111
+ return load_config(_config_path(args))
112
+ except ConfigError as e:
113
+ if e.about_values:
114
+ _err(msg.CONFIG_INVALID)
115
+ for problem in e.problems:
116
+ _err(f" {problem}")
117
+ else:
118
+ _err(e.problems[0])
119
+ return None
120
+
121
+
122
+ def _print_problems(problems):
123
+ for problem in problems:
124
+ prefix = msg.ERROR_PREFIX if problem.is_error else msg.WARNING_PREFIX
125
+ _err(f"{prefix}: {problem}")
126
+
127
+
128
+ def _instance() -> Instance:
129
+ return Instance(paths.state_dir())
130
+
131
+
132
+ # --- Commands ------------------------------------------------------------------------
133
+
134
+ def cmd_init(args) -> int:
135
+ path = _config_path(args)
136
+ try:
137
+ write_config(Config(), path, force=args.force)
138
+ except ConfigError as e:
139
+ _err(e.problems[0])
140
+ return EXIT_CONFIG
141
+ _out(msg.CONFIG_WRITTEN.format(path=path))
142
+ return EXIT_OK
143
+
144
+
145
+ def cmd_check(args) -> int:
146
+ config = _load(args)
147
+ if config is None:
148
+ return EXIT_CONFIG
149
+ problems = config.problems()
150
+ _print_problems(problems)
151
+ errors = sum(p.is_error for p in problems)
152
+ if problems:
153
+ _out(msg.CHECK_SUMMARY.format(errors=errors, warnings=len(problems) - errors, path=config.path))
154
+ else:
155
+ _out(msg.CHECK_OK.format(path=config.path))
156
+ return EXIT_CONFIG if errors else EXIT_OK
157
+
158
+
159
+ def cmd_preview(args) -> int:
160
+ config = _load(args)
161
+ if config is None:
162
+ return EXIT_CONFIG
163
+ problems = config.problems()
164
+ _print_problems(problems)
165
+ if any(p.is_error for p in problems):
166
+ return EXIT_CONFIG
167
+
168
+ now = datetime.now().astimezone()
169
+ activity, refresh_in = build_activity(config.presence, connected_at=now, started_at=now,
170
+ proxy_discord_cdn=config.proxy_discord_cdn)
171
+ _out(json.dumps({"application_id": config.presence.effective_application_id, "activity": activity},
172
+ ensure_ascii=False, indent=2))
173
+ return EXIT_OK
174
+
175
+
176
+ def cmd_run(args) -> int:
177
+ config = _load(args)
178
+ if config is None:
179
+ return EXIT_CONFIG
180
+
181
+ logs.setup(logs.level_for(config.log_level, args.verbose, args.quiet), config.log_file)
182
+ problems = config.problems()
183
+ for problem in problems:
184
+ (logging.error if problem.is_error else logging.warning)("%s", problem)
185
+ if any(p.is_error for p in problems):
186
+ return EXIT_CONFIG
187
+
188
+ return runner.run(config, _instance(), watch=not args.no_watch)
189
+
190
+
191
+ def cmd_stop(args) -> int:
192
+ instance = _instance()
193
+ pid = instance.running_pid()
194
+ if pid is None:
195
+ _out(msg.NOT_RUNNING)
196
+ return EXIT_OK
197
+
198
+ instance.request_stop()
199
+ _out(msg.STOP_SENT.format(pid=pid or "unknown"))
200
+ deadline = time.monotonic() + args.timeout
201
+ while time.monotonic() < deadline:
202
+ if instance.running_pid() is None:
203
+ _out(msg.STOPPED)
204
+ return EXIT_OK
205
+ time.sleep(0.1)
206
+ _err(msg.STOP_TIMEOUT.format(pid=pid or "unknown", timeout=args.timeout))
207
+ return EXIT_FAILED
208
+
209
+
210
+ def cmd_status(args) -> int:
211
+ instance = _instance()
212
+ pid = instance.running_pid()
213
+ if pid is None:
214
+ _out(msg.NOT_RUNNING)
215
+ return EXIT_NOT_RUNNING
216
+
217
+ _out(msg.RUNNING.format(pid=pid or "unknown"))
218
+ status = instance.read_status()
219
+ if status.get("state"):
220
+ detail = f" ({status['message']})" if status.get("message") else ""
221
+ _out(msg.STATUS_STATE.format(state=status["state"], detail=detail))
222
+ if status.get("user"):
223
+ _out(msg.STATUS_USER.format(user=status["user"]))
224
+ if status.get("config"):
225
+ _out(msg.STATUS_CONFIG.format(path=status["config"]))
226
+ if status.get("started"):
227
+ _out(msg.STATUS_SINCE.format(since=status["started"]))
228
+ return EXIT_OK
229
+
230
+
231
+ def cmd_import(args) -> int:
232
+ path = _config_path(args)
233
+ try:
234
+ presence = load_preset(args.preset)
235
+ except PresetError as e:
236
+ _err(str(e))
237
+ return EXIT_FAILED
238
+
239
+ config = Config()
240
+ if path.exists():
241
+ if not args.force:
242
+ _err(msg.CONFIG_EXISTS.format(path=path))
243
+ return EXIT_CONFIG
244
+ try:
245
+ config = load_config(path)
246
+ _out(msg.PRESET_KEPT_SETTINGS)
247
+ except ConfigError:
248
+ pass # Broken anyway; replace it whole
249
+
250
+ config.presence = presence
251
+ try:
252
+ write_config(config, path, force=True)
253
+ except ConfigError as e:
254
+ _err(e.problems[0])
255
+ return EXIT_CONFIG
256
+
257
+ _print_problems(config.problems())
258
+ _out(msg.PRESET_IMPORTED.format(preset=args.preset, path=path))
259
+ return EXIT_OK
260
+
261
+
262
+ def cmd_export(args) -> int:
263
+ config = _load(args)
264
+ if config is None:
265
+ return EXIT_CONFIG
266
+ if args.preset.exists() and not args.force:
267
+ _err(msg.FILE_EXISTS.format(path=args.preset))
268
+ return EXIT_FAILED
269
+ try:
270
+ save_preset(config.presence, args.preset)
271
+ except PresetError as e:
272
+ _err(str(e))
273
+ return EXIT_FAILED
274
+ _out(msg.PRESET_EXPORTED.format(path=config.path, preset=args.preset))
275
+ return EXIT_OK
276
+
277
+
278
+ def cmd_assets(args) -> int:
279
+ app_id = args.application_id
280
+ if not app_id:
281
+ config = _load(args)
282
+ if config is None:
283
+ return EXIT_CONFIG
284
+ app_id = config.presence.effective_application_id
285
+
286
+ try:
287
+ assets = fetch_assets(app_id)
288
+ page = assets_page_url(app_id)
289
+ except AssetsError as e:
290
+ _err(str(e))
291
+ return EXIT_FAILED
292
+
293
+ if not assets:
294
+ _out(msg.ASSETS_NONE.format(app_id=app_id))
295
+ else:
296
+ _out(msg.ASSETS_HEADER.format(app_id=app_id))
297
+ for asset in sorted(assets, key=lambda a: a.name.lower()):
298
+ _out(f" {asset.name}")
299
+ _out(msg.ASSETS_PAGE.format(url=page))
300
+ return EXIT_OK
301
+
302
+
303
+ def cmd_paths(args) -> int:
304
+ state = paths.state_dir()
305
+ _out(f"Config file: {_config_path(args)}")
306
+ _out(f"Config directory: {paths.config_dir()}")
307
+ _out(f"State directory: {state} (lock, PID and status files)")
308
+ return EXIT_OK
309
+
310
+
311
+ COMMANDS = {
312
+ "init": cmd_init,
313
+ "check": cmd_check,
314
+ "validate": cmd_check,
315
+ "preview": cmd_preview,
316
+ "run": cmd_run,
317
+ "stop": cmd_stop,
318
+ "status": cmd_status,
319
+ "import": cmd_import,
320
+ "export": cmd_export,
321
+ "assets": cmd_assets,
322
+ "paths": cmd_paths,
323
+ }
324
+
325
+
326
+ def main(argv: list[str] | None = None) -> int:
327
+ parser = build_parser()
328
+ args = parser.parse_args(argv)
329
+ if not args.command:
330
+ parser.print_help()
331
+ return EXIT_CONFIG
332
+
333
+ # Commands other than run log only library warnings (run sets up logging from the config)
334
+ logs.setup(logs.level_for("warning", args.verbose, args.quiet))
335
+ return COMMANDS[args.command](args)