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.
- customrp_cli/__init__.py +3 -0
- customrp_cli/__main__.py +8 -0
- customrp_cli/cli.py +335 -0
- customrp_cli/config.py +474 -0
- customrp_cli/instance.py +139 -0
- customrp_cli/logs.py +48 -0
- customrp_cli/messages.py +66 -0
- customrp_cli/paths.py +44 -0
- customrp_cli/runner.py +175 -0
- customrp_cli-0.1.1.dist-info/METADATA +287 -0
- customrp_cli-0.1.1.dist-info/RECORD +15 -0
- customrp_cli-0.1.1.dist-info/WHEEL +5 -0
- customrp_cli-0.1.1.dist-info/entry_points.txt +2 -0
- customrp_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
- customrp_cli-0.1.1.dist-info/top_level.txt +1 -0
customrp_cli/config.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""The TOML config file: loading it, checking it, and writing it.
|
|
2
|
+
|
|
3
|
+
The file has four tables: ``[presence]`` (what to show), ``[connection]``, ``[run]`` and
|
|
4
|
+
``[logging]``. docs/configuration.md describes every option.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import date, datetime, time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
if sys.version_info >= (3, 11):
|
|
19
|
+
import tomllib
|
|
20
|
+
else: # pragma: no cover
|
|
21
|
+
import tomli as tomllib
|
|
22
|
+
|
|
23
|
+
from customrp_core import ActivityType, Button, Presence, StatusDisplay, TimestampMode, validate
|
|
24
|
+
from customrp_core.session import DEFAULT_RETRY_INTERVAL
|
|
25
|
+
|
|
26
|
+
from . import messages as msg
|
|
27
|
+
|
|
28
|
+
ACTIVITY_TYPES = {
|
|
29
|
+
"playing": ActivityType.PLAYING,
|
|
30
|
+
"listening": ActivityType.LISTENING,
|
|
31
|
+
"watching": ActivityType.WATCHING,
|
|
32
|
+
"competing": ActivityType.COMPETING,
|
|
33
|
+
}
|
|
34
|
+
STATUS_DISPLAYS = {
|
|
35
|
+
"name": StatusDisplay.NAME,
|
|
36
|
+
"details": StatusDisplay.DETAILS,
|
|
37
|
+
"state": StatusDisplay.STATE,
|
|
38
|
+
}
|
|
39
|
+
TIMESTAMP_MODES = {
|
|
40
|
+
"since_last_connection": TimestampMode.SINCE_LAST_CONNECTION,
|
|
41
|
+
"since_startup": TimestampMode.SINCE_STARTUP,
|
|
42
|
+
"since_presence_update": TimestampMode.SINCE_PRESENCE_UPDATE,
|
|
43
|
+
"local_time": TimestampMode.LOCAL_TIME,
|
|
44
|
+
"custom": TimestampMode.CUSTOM,
|
|
45
|
+
}
|
|
46
|
+
LOG_LEVELS = ("debug", "info", "warning", "error")
|
|
47
|
+
|
|
48
|
+
DATETIME_EXAMPLE = "2026-01-01T12:00:00"
|
|
49
|
+
|
|
50
|
+
_SECTIONS = ("presence", "connection", "run", "logging")
|
|
51
|
+
_PRESENCE_TEXT = ("application_id", "name", "details", "details_url", "state", "state_url")
|
|
52
|
+
_PRESENCE_KEYS = _PRESENCE_TEXT + ("type", "status_display", "party_size", "party_max", "timestamps",
|
|
53
|
+
"custom_start", "custom_end", "proxy_discord_cdn", "large_image",
|
|
54
|
+
"small_image", "buttons")
|
|
55
|
+
_IMAGE_KEYS = ("key", "text", "url")
|
|
56
|
+
_BUTTON_KEYS = ("label", "url")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConfigError(Exception):
|
|
60
|
+
"""The config file can't be used. ``problems`` lists every reason, one line each.
|
|
61
|
+
|
|
62
|
+
``about_values`` is True when the file was read but some values are wrong, and False when
|
|
63
|
+
the file itself is the problem (missing, unreadable, not TOML).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, problems: list[str], about_values: bool = False):
|
|
67
|
+
super().__init__("\n".join(problems))
|
|
68
|
+
self.problems = problems
|
|
69
|
+
self.about_values = about_values
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Problem:
|
|
74
|
+
"""Something wrong with a config value. Only errors stop the presence from being sent."""
|
|
75
|
+
|
|
76
|
+
key: str
|
|
77
|
+
message: str
|
|
78
|
+
is_error: bool = True
|
|
79
|
+
|
|
80
|
+
def __str__(self):
|
|
81
|
+
return f"{self.key}: {self.message}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Config:
|
|
86
|
+
presence: Presence = field(default_factory=Presence)
|
|
87
|
+
proxy_discord_cdn: bool = True
|
|
88
|
+
pipe: int = -1 # -1 means "auto"
|
|
89
|
+
retry_interval: float = DEFAULT_RETRY_INTERVAL
|
|
90
|
+
max_retries: int | None = None # None means forever
|
|
91
|
+
watch_config: bool = True
|
|
92
|
+
log_level: str = "info"
|
|
93
|
+
log_file: Path | None = None
|
|
94
|
+
#: Where the config was loaded from, if anywhere
|
|
95
|
+
path: Path | None = None
|
|
96
|
+
|
|
97
|
+
def problems(self) -> list[Problem]:
|
|
98
|
+
"""Checks the presence the way Discord would; see customrp_core.validate."""
|
|
99
|
+
return [Problem(_issue_key(issue.field), issue.message, issue.is_error)
|
|
100
|
+
for issue in validate(self.presence)]
|
|
101
|
+
|
|
102
|
+
def same_runtime_settings(self, other: Config) -> bool:
|
|
103
|
+
"""Whether the settings that need a restart to change are the same."""
|
|
104
|
+
keys = ("pipe", "retry_interval", "max_retries", "log_level", "log_file")
|
|
105
|
+
return all(getattr(self, k) == getattr(other, k) for k in keys)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --- Loading -----------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def load_config(path: Path) -> Config:
|
|
111
|
+
"""Reads and parses a config file. Raises :class:`ConfigError` if it can't be used.
|
|
112
|
+
|
|
113
|
+
Validation problems in the presence itself aren't raised; see :meth:`Config.problems`.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
raw = path.read_bytes()
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
raise ConfigError([msg.CONFIG_MISSING.format(path=path)]) from None
|
|
119
|
+
except OSError as e:
|
|
120
|
+
raise ConfigError([msg.CONFIG_UNREADABLE.format(path=path, reason=e.strerror or e)]) from None
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
text = raw.decode("utf-8-sig") # Tolerate the BOM some Windows editors add
|
|
124
|
+
except UnicodeDecodeError:
|
|
125
|
+
raise ConfigError([msg.CONFIG_NOT_UTF8.format(path=path)]) from None
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
document = tomllib.loads(text)
|
|
129
|
+
except tomllib.TOMLDecodeError as e:
|
|
130
|
+
raise ConfigError([msg.CONFIG_BAD_TOML.format(path=path, reason=e)]) from None
|
|
131
|
+
|
|
132
|
+
config = parse_config(document, base_dir=path.parent)
|
|
133
|
+
config.path = path
|
|
134
|
+
return config
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_config(document: dict, base_dir: Path | None = None) -> Config:
|
|
138
|
+
"""Builds a Config from parsed TOML. Raises :class:`ConfigError` listing every problem found."""
|
|
139
|
+
reader = _Reader()
|
|
140
|
+
config = Config()
|
|
141
|
+
reader.check_keys(document, "", _SECTIONS)
|
|
142
|
+
|
|
143
|
+
presence = reader.table(document, "", "presence")
|
|
144
|
+
reader.check_keys(presence, "presence", _PRESENCE_KEYS)
|
|
145
|
+
config.presence = _read_presence(reader, presence)
|
|
146
|
+
config.proxy_discord_cdn = reader.boolean(presence, "presence", "proxy_discord_cdn", True)
|
|
147
|
+
|
|
148
|
+
connection = reader.table(document, "", "connection")
|
|
149
|
+
reader.check_keys(connection, "connection", ("pipe", "retry_interval", "max_retries"))
|
|
150
|
+
config.pipe = reader.pipe(connection, "connection", "pipe")
|
|
151
|
+
config.retry_interval = reader.number(connection, "connection", "retry_interval", DEFAULT_RETRY_INTERVAL,
|
|
152
|
+
minimum=1)
|
|
153
|
+
retries = reader.integer(connection, "connection", "max_retries", -1, minimum=-1)
|
|
154
|
+
config.max_retries = None if retries == -1 else retries
|
|
155
|
+
|
|
156
|
+
run = reader.table(document, "", "run")
|
|
157
|
+
reader.check_keys(run, "run", ("watch_config",))
|
|
158
|
+
config.watch_config = reader.boolean(run, "run", "watch_config", True)
|
|
159
|
+
|
|
160
|
+
logging_ = reader.table(document, "", "logging")
|
|
161
|
+
reader.check_keys(logging_, "logging", ("level", "file"))
|
|
162
|
+
config.log_level = reader.choice(logging_, "logging", "level", "info", {name: name for name in LOG_LEVELS})
|
|
163
|
+
log_file = reader.string(logging_, "logging", "file")
|
|
164
|
+
if log_file:
|
|
165
|
+
log_path = Path(log_file).expanduser()
|
|
166
|
+
if not log_path.is_absolute() and base_dir is not None:
|
|
167
|
+
log_path = base_dir / log_path
|
|
168
|
+
config.log_file = log_path
|
|
169
|
+
|
|
170
|
+
if reader.problems:
|
|
171
|
+
raise ConfigError(reader.problems, about_values=True)
|
|
172
|
+
return config
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _read_presence(reader: _Reader, table: dict) -> Presence:
|
|
176
|
+
p = Presence()
|
|
177
|
+
for name in _PRESENCE_TEXT:
|
|
178
|
+
setattr(p, name, reader.string(table, "presence", name))
|
|
179
|
+
p.type = reader.choice(table, "presence", "type", ActivityType.PLAYING, ACTIVITY_TYPES)
|
|
180
|
+
p.status_display = reader.choice(table, "presence", "status_display", StatusDisplay.NAME, STATUS_DISPLAYS)
|
|
181
|
+
p.party_size = reader.integer(table, "presence", "party_size", 0, minimum=0)
|
|
182
|
+
p.party_max = reader.integer(table, "presence", "party_max", 0, minimum=0)
|
|
183
|
+
p.timestamps = reader.choice(table, "presence", "timestamps", TimestampMode.SINCE_LAST_CONNECTION,
|
|
184
|
+
TIMESTAMP_MODES)
|
|
185
|
+
p.custom_start = reader.moment(table, "presence", "custom_start")
|
|
186
|
+
p.custom_end = reader.moment(table, "presence", "custom_end")
|
|
187
|
+
|
|
188
|
+
for prefix in ("large", "small"):
|
|
189
|
+
name = prefix + "_image"
|
|
190
|
+
image = reader.table(table, "presence", name)
|
|
191
|
+
reader.check_keys(image, f"presence.{name}", _IMAGE_KEYS)
|
|
192
|
+
setattr(p, name, reader.string(image, f"presence.{name}", "key"))
|
|
193
|
+
setattr(p, prefix + "_text", reader.string(image, f"presence.{name}", "text"))
|
|
194
|
+
setattr(p, prefix + "_url", reader.string(image, f"presence.{name}", "url"))
|
|
195
|
+
|
|
196
|
+
buttons = reader.array_of_tables(table, "presence", "buttons")
|
|
197
|
+
if len(buttons) > 2:
|
|
198
|
+
reader.error("presence.buttons", msg.TOO_MANY_BUTTONS.format(count=len(buttons)))
|
|
199
|
+
for n, button in enumerate(buttons[:2], 1):
|
|
200
|
+
prefix = f"presence.buttons[{n}]"
|
|
201
|
+
reader.check_keys(button, prefix, _BUTTON_KEYS)
|
|
202
|
+
setattr(p, f"button{n}", Button(reader.string(button, prefix, "label"), reader.string(button, prefix, "url")))
|
|
203
|
+
|
|
204
|
+
return p
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _join(prefix: str, key: str) -> str:
|
|
208
|
+
return f"{prefix}.{key}" if prefix else key
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _type_name(value) -> str:
|
|
212
|
+
if isinstance(value, bool):
|
|
213
|
+
return msg.TYPE_NAMES[bool]
|
|
214
|
+
if isinstance(value, datetime):
|
|
215
|
+
return "a date and time"
|
|
216
|
+
if isinstance(value, date):
|
|
217
|
+
return "a date"
|
|
218
|
+
if isinstance(value, time):
|
|
219
|
+
return "a time"
|
|
220
|
+
return msg.TYPE_NAMES.get(type(value), type(value).__name__)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _Reader:
|
|
224
|
+
"""Reads typed values out of parsed TOML, collecting problems instead of stopping at the first."""
|
|
225
|
+
|
|
226
|
+
def __init__(self):
|
|
227
|
+
self.problems: list[str] = []
|
|
228
|
+
|
|
229
|
+
def error(self, key: str, message: str):
|
|
230
|
+
self.problems.append(f"{key}: {message}")
|
|
231
|
+
|
|
232
|
+
def check_keys(self, table: dict, prefix: str, allowed):
|
|
233
|
+
for key in table:
|
|
234
|
+
if key not in allowed:
|
|
235
|
+
close = difflib.get_close_matches(key, allowed, n=1)
|
|
236
|
+
hint = msg.DID_YOU_MEAN.format(suggestion=close[0]) if close else ""
|
|
237
|
+
self.error(_join(prefix, key), msg.UNKNOWN_OPTION + hint)
|
|
238
|
+
|
|
239
|
+
def _get(self, table: dict, prefix: str, name: str, accept: tuple, expected: str):
|
|
240
|
+
"""Returns the value, or None when it's missing or has the wrong type (which is reported)."""
|
|
241
|
+
if name not in table:
|
|
242
|
+
return None
|
|
243
|
+
value = table[name]
|
|
244
|
+
if isinstance(value, accept) and not (isinstance(value, bool) and bool not in accept):
|
|
245
|
+
return value
|
|
246
|
+
self.error(_join(prefix, name), msg.EXPECTED_TYPE.format(expected=expected, actual=_type_name(value)))
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
def table(self, table: dict, prefix: str, name: str) -> dict:
|
|
250
|
+
value = self._get(table, prefix, name, (dict,), msg.TYPE_NAMES[dict])
|
|
251
|
+
return value if value is not None else {}
|
|
252
|
+
|
|
253
|
+
def array_of_tables(self, table: dict, prefix: str, name: str) -> list:
|
|
254
|
+
value = self._get(table, prefix, name, (list,), "an array of tables")
|
|
255
|
+
if value is None:
|
|
256
|
+
return []
|
|
257
|
+
if not all(isinstance(item, dict) for item in value):
|
|
258
|
+
self.error(_join(prefix, name), msg.EXPECTED_TYPE.format(expected="an array of tables",
|
|
259
|
+
actual="an array of other values"))
|
|
260
|
+
return []
|
|
261
|
+
return value
|
|
262
|
+
|
|
263
|
+
def string(self, table: dict, prefix: str, name: str, default: str = "") -> str:
|
|
264
|
+
value = self._get(table, prefix, name, (str,), msg.TYPE_NAMES[str])
|
|
265
|
+
return default if value is None else value
|
|
266
|
+
|
|
267
|
+
def boolean(self, table: dict, prefix: str, name: str, default: bool) -> bool:
|
|
268
|
+
value = self._get(table, prefix, name, (bool,), msg.TYPE_NAMES[bool])
|
|
269
|
+
return default if value is None else value
|
|
270
|
+
|
|
271
|
+
def integer(self, table: dict, prefix: str, name: str, default: int, minimum: int | None = None) -> int:
|
|
272
|
+
value = self._get(table, prefix, name, (int,), msg.TYPE_NAMES[int])
|
|
273
|
+
if value is None:
|
|
274
|
+
return default
|
|
275
|
+
if minimum is not None and value < minimum:
|
|
276
|
+
self.error(_join(prefix, name), msg.AT_LEAST.format(minimum=minimum))
|
|
277
|
+
return default
|
|
278
|
+
return value
|
|
279
|
+
|
|
280
|
+
def number(self, table: dict, prefix: str, name: str, default: float, minimum: float) -> float:
|
|
281
|
+
value = self._get(table, prefix, name, (int, float), msg.TYPE_NAMES[float])
|
|
282
|
+
if value is None:
|
|
283
|
+
return default
|
|
284
|
+
if not math.isfinite(value) or value < minimum:
|
|
285
|
+
self.error(_join(prefix, name), msg.AT_LEAST.format(minimum=minimum))
|
|
286
|
+
return default
|
|
287
|
+
return float(value)
|
|
288
|
+
|
|
289
|
+
def choice(self, table: dict, prefix: str, name: str, default, choices: dict):
|
|
290
|
+
value = self._get(table, prefix, name, (str,), msg.TYPE_NAMES[str])
|
|
291
|
+
if value is None:
|
|
292
|
+
return default
|
|
293
|
+
normalized = re.sub(r"[\s-]+", "_", value.strip().lower())
|
|
294
|
+
if normalized not in choices:
|
|
295
|
+
self.error(_join(prefix, name), msg.EXPECTED_CHOICE.format(value=value, choices=", ".join(choices)))
|
|
296
|
+
return default
|
|
297
|
+
return choices[normalized]
|
|
298
|
+
|
|
299
|
+
def moment(self, table: dict, prefix: str, name: str) -> datetime | None:
|
|
300
|
+
if name not in table:
|
|
301
|
+
return None
|
|
302
|
+
value = table[name]
|
|
303
|
+
key = _join(prefix, name)
|
|
304
|
+
if isinstance(value, datetime):
|
|
305
|
+
return value
|
|
306
|
+
if isinstance(value, date):
|
|
307
|
+
self.error(key, msg.NEEDS_TIME.format(example=f"{value.isoformat()}T00:00:00"))
|
|
308
|
+
return None
|
|
309
|
+
if isinstance(value, str):
|
|
310
|
+
text = value.strip()
|
|
311
|
+
if text.endswith(("Z", "z")):
|
|
312
|
+
text = text[:-1] + "+00:00"
|
|
313
|
+
try:
|
|
314
|
+
parsed = datetime.fromisoformat(text)
|
|
315
|
+
except ValueError:
|
|
316
|
+
self.error(key, msg.BAD_DATETIME.format(value=value, example=DATETIME_EXAMPLE))
|
|
317
|
+
return None
|
|
318
|
+
if "T" not in text and " " not in text:
|
|
319
|
+
self.error(key, msg.NEEDS_TIME.format(example=f"{text}T00:00:00"))
|
|
320
|
+
return None
|
|
321
|
+
return parsed
|
|
322
|
+
self.error(key, msg.EXPECTED_TYPE.format(expected="a date and time", actual=_type_name(value)))
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
def pipe(self, table: dict, prefix: str, name: str) -> int:
|
|
326
|
+
if name not in table:
|
|
327
|
+
return -1
|
|
328
|
+
value = table[name]
|
|
329
|
+
if isinstance(value, str) and value.strip().lower() == "auto":
|
|
330
|
+
return -1
|
|
331
|
+
if isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 9:
|
|
332
|
+
return value
|
|
333
|
+
self.error(_join(prefix, name), msg.EXPECTED_TYPE.format(expected=msg.PIPE_HINT, actual=repr(value)))
|
|
334
|
+
return -1
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
_ISSUE_KEYS = {
|
|
338
|
+
"large_image": "presence.large_image.key",
|
|
339
|
+
"large_text": "presence.large_image.text",
|
|
340
|
+
"large_url": "presence.large_image.url",
|
|
341
|
+
"small_image": "presence.small_image.key",
|
|
342
|
+
"small_text": "presence.small_image.text",
|
|
343
|
+
"small_url": "presence.small_image.url",
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _issue_key(field_name: str | None) -> str:
|
|
348
|
+
"""Translates a customrp_core field name into the matching config key."""
|
|
349
|
+
if field_name is None:
|
|
350
|
+
return "presence"
|
|
351
|
+
if field_name in _ISSUE_KEYS:
|
|
352
|
+
return _ISSUE_KEYS[field_name]
|
|
353
|
+
m = re.fullmatch(r"button(\d)(?:\.(\w+))?", field_name)
|
|
354
|
+
if m:
|
|
355
|
+
return f"presence.buttons[{m[1]}]" + (f".{m[2]}" if m[2] else "")
|
|
356
|
+
return f"presence.{field_name}"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# --- Writing -----------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def toml_value(value) -> str:
|
|
362
|
+
"""Formats a value as TOML. JSON strings are valid TOML basic strings, except for DEL."""
|
|
363
|
+
if isinstance(value, bool):
|
|
364
|
+
return "true" if value else "false"
|
|
365
|
+
if isinstance(value, (int, float)):
|
|
366
|
+
return repr(value)
|
|
367
|
+
if isinstance(value, datetime):
|
|
368
|
+
return value.isoformat()
|
|
369
|
+
return json.dumps(str(value), ensure_ascii=False).replace("\x7f", "\\u007f")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _name_of(choices: dict, value) -> str:
|
|
373
|
+
return next(name for name, member in choices.items() if member == value)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def render_config(config: Config) -> str:
|
|
377
|
+
"""Writes a complete, commented config file. Loading it gives back an equal Config."""
|
|
378
|
+
p = config.presence
|
|
379
|
+
v = toml_value
|
|
380
|
+
|
|
381
|
+
def optional_datetime(name, value):
|
|
382
|
+
if value is None:
|
|
383
|
+
return f"# {name} = {DATETIME_EXAMPLE}"
|
|
384
|
+
return f"{name} = {v(value)}"
|
|
385
|
+
|
|
386
|
+
buttons = [b for b in p.buttons if b.label or b.url]
|
|
387
|
+
if buttons:
|
|
388
|
+
button_text = "\n\n".join(f"[[presence.buttons]]\nlabel = {v(b.label)}\nurl = {v(b.url)}" for b in buttons)
|
|
389
|
+
else:
|
|
390
|
+
button_text = '# [[presence.buttons]]\n# label = "My website"\n# url = "https://example.com"'
|
|
391
|
+
|
|
392
|
+
retry = int(config.retry_interval) if float(config.retry_interval).is_integer() else config.retry_interval
|
|
393
|
+
|
|
394
|
+
return f"""\
|
|
395
|
+
# customrp-cli configuration
|
|
396
|
+
# Every option is described in https://github.com/KeiraOMG0/customrp-cli/blob/main/docs/configuration.md
|
|
397
|
+
# While customrp-cli runs, it reloads this file whenever it's saved.
|
|
398
|
+
|
|
399
|
+
[presence]
|
|
400
|
+
# Your Discord application's ID, from https://discord.com/developers/applications
|
|
401
|
+
# Leave it empty to use CustomRP's default application.
|
|
402
|
+
application_id = {v(p.application_id)}
|
|
403
|
+
|
|
404
|
+
# playing, listening, watching or competing
|
|
405
|
+
type = {v(_name_of(ACTIVITY_TYPES, p.type))}
|
|
406
|
+
# What the member list shows next to your name: name (the application's name), details or state
|
|
407
|
+
status_display = {v(_name_of(STATUS_DISPLAYS, p.status_display))}
|
|
408
|
+
|
|
409
|
+
# Sent as the activity's name
|
|
410
|
+
name = {v(p.name)}
|
|
411
|
+
# The two lines of text, each with an optional link
|
|
412
|
+
details = {v(p.details)}
|
|
413
|
+
details_url = {v(p.details_url)}
|
|
414
|
+
state = {v(p.state)}
|
|
415
|
+
state_url = {v(p.state_url)}
|
|
416
|
+
|
|
417
|
+
# Shown as "(1 of 4)" with the playing type, when both are above 0
|
|
418
|
+
party_size = {v(p.party_size)}
|
|
419
|
+
party_max = {v(p.party_max)}
|
|
420
|
+
|
|
421
|
+
# since_last_connection, since_startup, since_presence_update, local_time or custom
|
|
422
|
+
timestamps = {v(_name_of(TIMESTAMP_MODES, p.timestamps))}
|
|
423
|
+
# For custom: a start, and optionally an end. Times without an offset (like +02:00) are local.
|
|
424
|
+
# A start in the future counts down to it.
|
|
425
|
+
{optional_datetime("custom_start", p.custom_start)}
|
|
426
|
+
{optional_datetime("custom_end", p.custom_end)}
|
|
427
|
+
|
|
428
|
+
# Send images hosted on Discord's CDN through CustomRP's proxy, since Discord won't show them directly
|
|
429
|
+
proxy_discord_cdn = {v(config.proxy_discord_cdn)}
|
|
430
|
+
|
|
431
|
+
[presence.large_image]
|
|
432
|
+
# An asset name from your application, or an image URL
|
|
433
|
+
key = {v(p.large_image)}
|
|
434
|
+
# Tooltip, and the link opened when the image is clicked
|
|
435
|
+
text = {v(p.large_text)}
|
|
436
|
+
url = {v(p.large_url)}
|
|
437
|
+
|
|
438
|
+
[presence.small_image]
|
|
439
|
+
key = {v(p.small_image)}
|
|
440
|
+
text = {v(p.small_text)}
|
|
441
|
+
url = {v(p.small_url)}
|
|
442
|
+
|
|
443
|
+
# Up to two buttons, each with a label (at most 31 bytes) and a URL
|
|
444
|
+
{button_text}
|
|
445
|
+
|
|
446
|
+
[connection]
|
|
447
|
+
# Which Discord to connect to: "auto", or 0 to 9 to pick one when several Discord clients run
|
|
448
|
+
pipe = {v("auto") if config.pipe < 0 else v(config.pipe)}
|
|
449
|
+
# Seconds between connection attempts while Discord isn't running
|
|
450
|
+
retry_interval = {v(retry)}
|
|
451
|
+
# How many times to retry before giving up; -1 retries forever
|
|
452
|
+
max_retries = {v(-1 if config.max_retries is None else config.max_retries)}
|
|
453
|
+
|
|
454
|
+
[run]
|
|
455
|
+
# Reload this file when it changes
|
|
456
|
+
watch_config = {v(config.watch_config)}
|
|
457
|
+
|
|
458
|
+
[logging]
|
|
459
|
+
# debug, info, warning or error
|
|
460
|
+
level = {v(config.log_level)}
|
|
461
|
+
# Also write the log to this file (relative to this config file's folder). Empty for none.
|
|
462
|
+
file = {v(str(config.log_file) if config.log_file else "")}
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def write_config(config: Config, path: Path, *, force: bool = False) -> None:
|
|
467
|
+
"""Writes a config file, refusing to overwrite unless ``force``. Raises :class:`ConfigError`."""
|
|
468
|
+
if path.exists() and not force:
|
|
469
|
+
raise ConfigError([msg.CONFIG_EXISTS.format(path=path)])
|
|
470
|
+
try:
|
|
471
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
472
|
+
path.write_text(render_config(config), encoding="utf-8")
|
|
473
|
+
except OSError as e:
|
|
474
|
+
raise ConfigError([msg.CANT_WRITE.format(path=path, reason=e.strerror or e)]) from None
|
customrp_cli/instance.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Keeping to one running instance per user, and letting other commands talk to it.
|
|
2
|
+
|
|
3
|
+
The running instance holds an exclusive lock on ``customrp-cli.lock`` in the state directory. The
|
|
4
|
+
OS releases the lock if the process dies, so a stale file never blocks a new start. Other commands
|
|
5
|
+
find the instance through files next to the lock:
|
|
6
|
+
|
|
7
|
+
* ``customrp-cli.pid``: the running instance's process ID
|
|
8
|
+
* ``status.json``: its current connection state, for ``customrp-cli status``
|
|
9
|
+
* ``stop-request``: created by ``customrp-cli stop``; the instance exits when it sees it
|
|
10
|
+
|
|
11
|
+
A stop file works the same on every platform. Signals don't: Windows has no SIGTERM to send
|
|
12
|
+
to another console process.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
LOCK_FILE = "customrp-cli.lock"
|
|
23
|
+
PID_FILE = "customrp-cli.pid"
|
|
24
|
+
STATUS_FILE = "status.json"
|
|
25
|
+
STOP_FILE = "stop-request"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _try_lock(handle) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
if sys.platform == "win32":
|
|
31
|
+
import msvcrt
|
|
32
|
+
handle.seek(0)
|
|
33
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
34
|
+
else:
|
|
35
|
+
import fcntl
|
|
36
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
37
|
+
return True
|
|
38
|
+
except OSError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _unlock(handle):
|
|
43
|
+
try:
|
|
44
|
+
if sys.platform == "win32":
|
|
45
|
+
import msvcrt
|
|
46
|
+
handle.seek(0)
|
|
47
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
48
|
+
else:
|
|
49
|
+
import fcntl
|
|
50
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Instance:
|
|
56
|
+
"""The running instance's files in ``directory``."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, directory: Path):
|
|
59
|
+
self.directory = directory
|
|
60
|
+
self._handle = None
|
|
61
|
+
|
|
62
|
+
def _file(self, name: str) -> Path:
|
|
63
|
+
return self.directory / name
|
|
64
|
+
|
|
65
|
+
# --- Used by the running instance ----------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def acquire(self) -> bool:
|
|
68
|
+
"""Takes the lock. Returns False if another instance holds it."""
|
|
69
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
handle = open(self._file(LOCK_FILE), "a+b")
|
|
71
|
+
if not _try_lock(handle):
|
|
72
|
+
handle.close()
|
|
73
|
+
return False
|
|
74
|
+
self._handle = handle
|
|
75
|
+
self._file(PID_FILE).write_text(str(os.getpid()), encoding="ascii")
|
|
76
|
+
self.clear_stop_request()
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
def release(self):
|
|
80
|
+
if self._handle is None:
|
|
81
|
+
return
|
|
82
|
+
for name in (PID_FILE, STATUS_FILE, STOP_FILE):
|
|
83
|
+
try:
|
|
84
|
+
self._file(name).unlink()
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
_unlock(self._handle)
|
|
88
|
+
self._handle.close()
|
|
89
|
+
self._handle = None
|
|
90
|
+
|
|
91
|
+
def write_status(self, status: dict):
|
|
92
|
+
tmp = self._file(STATUS_FILE + ".tmp")
|
|
93
|
+
try:
|
|
94
|
+
tmp.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
95
|
+
os.replace(tmp, self._file(STATUS_FILE))
|
|
96
|
+
except OSError:
|
|
97
|
+
pass # Status is informational; never let it stop the presence
|
|
98
|
+
|
|
99
|
+
def stop_requested(self) -> bool:
|
|
100
|
+
return self._file(STOP_FILE).exists()
|
|
101
|
+
|
|
102
|
+
def clear_stop_request(self):
|
|
103
|
+
try:
|
|
104
|
+
self._file(STOP_FILE).unlink()
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# --- Used by other commands ---------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def running_pid(self) -> int | None:
|
|
111
|
+
"""The running instance's PID, 0 if it's running but the PID is unknown, or None if it isn't running."""
|
|
112
|
+
lock = self._file(LOCK_FILE)
|
|
113
|
+
if not lock.exists():
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
handle = open(lock, "a+b")
|
|
117
|
+
except OSError:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
if _try_lock(handle):
|
|
121
|
+
_unlock(handle)
|
|
122
|
+
return None
|
|
123
|
+
finally:
|
|
124
|
+
handle.close()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
return int(self._file(PID_FILE).read_text(encoding="ascii").strip())
|
|
128
|
+
except (OSError, ValueError):
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
def read_status(self) -> dict:
|
|
132
|
+
try:
|
|
133
|
+
return json.loads(self._file(STATUS_FILE).read_text(encoding="utf-8"))
|
|
134
|
+
except (OSError, ValueError):
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
def request_stop(self):
|
|
138
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
self._file(STOP_FILE).write_text(str(os.getpid()), encoding="ascii")
|
customrp_cli/logs.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Logging setup: to stderr, and optionally to a rotating file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import logging.handlers
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
FORMAT = "%(asctime)s %(levelname)-7s %(message)s"
|
|
11
|
+
DEBUG_FORMAT = "%(asctime)s %(levelname)-7s %(name)s: %(message)s"
|
|
12
|
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
13
|
+
|
|
14
|
+
# Rotate the log file at 1 MB and keep 3 old ones, so it can't fill a disk
|
|
15
|
+
MAX_BYTES = 1_000_000
|
|
16
|
+
BACKUP_COUNT = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def level_for(config_level: str, verbose: int, quiet: bool) -> int:
|
|
20
|
+
"""-v and -q on the command line override the config file's level."""
|
|
21
|
+
if quiet:
|
|
22
|
+
return logging.ERROR
|
|
23
|
+
if verbose:
|
|
24
|
+
return logging.DEBUG
|
|
25
|
+
return getattr(logging, config_level.upper(), logging.INFO)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def setup(level: int, log_file: Path | None = None) -> None:
|
|
29
|
+
root = logging.getLogger()
|
|
30
|
+
for handler in list(root.handlers):
|
|
31
|
+
root.removeHandler(handler)
|
|
32
|
+
handler.close()
|
|
33
|
+
root.setLevel(level)
|
|
34
|
+
|
|
35
|
+
formatter = logging.Formatter(DEBUG_FORMAT if level <= logging.DEBUG else FORMAT, DATE_FORMAT)
|
|
36
|
+
|
|
37
|
+
# pythonw (used to run in the background on Windows) has no stderr
|
|
38
|
+
if sys.stderr is not None:
|
|
39
|
+
console = logging.StreamHandler(sys.stderr)
|
|
40
|
+
console.setFormatter(formatter)
|
|
41
|
+
root.addHandler(console)
|
|
42
|
+
|
|
43
|
+
if log_file is not None:
|
|
44
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
file_handler = logging.handlers.RotatingFileHandler(log_file, maxBytes=MAX_BYTES,
|
|
46
|
+
backupCount=BACKUP_COUNT, encoding="utf-8")
|
|
47
|
+
file_handler.setFormatter(formatter)
|
|
48
|
+
root.addHandler(file_handler)
|