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/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
@@ -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)