draftomen 0.3.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.
- draftomen/__init__.py +17 -0
- draftomen/assets/draftomen.icns +0 -0
- draftomen/assets/draftomen.ico +0 -0
- draftomen/assets/draftomen_logo.png +0 -0
- draftomen/audit.py +606 -0
- draftomen/backtest.py +449 -0
- draftomen/benchmark.py +834 -0
- draftomen/carddb.py +1869 -0
- draftomen/cardimages.py +313 -0
- draftomen/cli.py +891 -0
- draftomen/config.py +133 -0
- draftomen/deckbuilder.py +2723 -0
- draftomen/events.py +772 -0
- draftomen/logfollow.py +451 -0
- draftomen/mock_session.py +745 -0
- draftomen/paths.py +120 -0
- draftomen/pickengine.py +1117 -0
- draftomen/pool.py +1259 -0
- draftomen/preferences.py +289 -0
- draftomen/qml/AboutDialog.qml +156 -0
- draftomen/qml/AppBar.qml +120 -0
- draftomen/qml/BacktestView.qml +316 -0
- draftomen/qml/BuildView.qml +1151 -0
- draftomen/qml/CardPreview.qml +434 -0
- draftomen/qml/DimensionalButton.qml +42 -0
- draftomen/qml/DimensionalComboBox.qml +182 -0
- draftomen/qml/DimensionalSurface.qml +118 -0
- draftomen/qml/DimensionalTabButton.qml +41 -0
- draftomen/qml/LiveDraftView.qml +468 -0
- draftomen/qml/Main.qml +175 -0
- draftomen/qml/NavigationRail.qml +226 -0
- draftomen/qml/PoolSummaryPanel.qml +322 -0
- draftomen/qml/PrivacyDialog.qml +96 -0
- draftomen/qml/RecentPickThumbnail.qml +83 -0
- draftomen/qml/RecentPicksGallery.qml +333 -0
- draftomen/qml/RecommendationRow.qml +404 -0
- draftomen/qml/SettingsSwitch.qml +141 -0
- draftomen/qml/SettingsView.qml +531 -0
- draftomen/qml/StateBanner.qml +189 -0
- draftomen/qml/StatusStrip.qml +84 -0
- draftomen/qml/Theme.qml +71 -0
- draftomen/qml/qmldir +23 -0
- draftomen/qt_adapter.py +884 -0
- draftomen/qt_gui.py +338 -0
- draftomen/qt_mock.py +63 -0
- draftomen/ranking.py +126 -0
- draftomen/replay.py +480 -0
- draftomen/session.py +3668 -0
- draftomen/setinfo.py +26 -0
- draftomen/seventeen.py +2766 -0
- draftomen/splash.py +617 -0
- draftomen/tui.py +4007 -0
- draftomen/watch.py +336 -0
- draftomen-0.3.0.dist-info/METADATA +156 -0
- draftomen-0.3.0.dist-info/RECORD +59 -0
- draftomen-0.3.0.dist-info/WHEEL +5 -0
- draftomen-0.3.0.dist-info/entry_points.txt +6 -0
- draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
- draftomen-0.3.0.dist-info/top_level.txt +1 -0
draftomen/logfollow.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Offset-persisted Player.log follower.
|
|
2
|
+
Emit complete raw lines while tolerating Arena log rotation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterator, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from os import PathLike
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, BinaryIO, TypeAlias
|
|
17
|
+
|
|
18
|
+
from draftomen.config import POLL_INTERVAL_SECONDS
|
|
19
|
+
from draftomen.paths import app_data_dir
|
|
20
|
+
|
|
21
|
+
PathInput: TypeAlias = str | PathLike[str]
|
|
22
|
+
|
|
23
|
+
OFFSET_DIRECTORY_NAME = "logfollow"
|
|
24
|
+
OFFSET_SCHEMA_VERSION = 1
|
|
25
|
+
STATE_HASH_LENGTH = 16
|
|
26
|
+
FINGERPRINT_SAMPLE_BYTES = 4096
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LogFollowError(RuntimeError):
|
|
30
|
+
"""Raised when follower offset state is malformed.
|
|
31
|
+
Callers should surface the message as a watch-mode diagnostic.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class FileIdentity:
|
|
37
|
+
"""Stable file identity used to detect replacement.
|
|
38
|
+
Device and inode map to stat fields on supported platforms.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
device: int
|
|
42
|
+
inode: int
|
|
43
|
+
|
|
44
|
+
def to_json(self) -> dict[str, int]:
|
|
45
|
+
"""Convert the identity to persisted JSON.
|
|
46
|
+
Integer fields keep the state portable and inspectable.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
return {"device": self.device, "inode": self.inode}
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_json(cls, data: Mapping[str, Any]) -> FileIdentity:
|
|
53
|
+
"""Load a file identity from persisted JSON.
|
|
54
|
+
Strict parsing keeps corrupted state from causing silent skips.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
return cls(
|
|
58
|
+
device=_required_int(data.get("device"), field_name="file_identity.device"),
|
|
59
|
+
inode=_required_int(data.get("inode"), field_name="file_identity.inode"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class OffsetState:
|
|
65
|
+
"""Persisted byte offset for one followed log path.
|
|
66
|
+
The offset always points to the last emitted newline boundary.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
log_path: str
|
|
70
|
+
offset: int
|
|
71
|
+
file_identity: FileIdentity
|
|
72
|
+
content_fingerprint: str | None
|
|
73
|
+
|
|
74
|
+
def to_json(self) -> dict[str, object]:
|
|
75
|
+
"""Convert the state to persisted JSON.
|
|
76
|
+
The log path guards against accidental hash collisions.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"schema_version": OFFSET_SCHEMA_VERSION,
|
|
81
|
+
"log_path": self.log_path,
|
|
82
|
+
"offset": self.offset,
|
|
83
|
+
"file_identity": self.file_identity.to_json(),
|
|
84
|
+
"content_fingerprint": self.content_fingerprint,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_json(cls, data: Mapping[str, Any]) -> OffsetState:
|
|
89
|
+
"""Load offset state from JSON.
|
|
90
|
+
Invalid schemas fail before any log bytes are skipped.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
schema_version = _required_int(
|
|
94
|
+
data.get("schema_version"),
|
|
95
|
+
field_name="schema_version",
|
|
96
|
+
)
|
|
97
|
+
if schema_version != OFFSET_SCHEMA_VERSION:
|
|
98
|
+
raise LogFollowError(
|
|
99
|
+
"Unsupported log follower schema "
|
|
100
|
+
f"{schema_version}; expected {OFFSET_SCHEMA_VERSION}."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
identity_value = data.get("file_identity")
|
|
104
|
+
if not isinstance(identity_value, Mapping):
|
|
105
|
+
raise LogFollowError("Missing or invalid file_identity; expected object.")
|
|
106
|
+
|
|
107
|
+
offset = _required_int(data.get("offset"), field_name="offset")
|
|
108
|
+
if offset < 0:
|
|
109
|
+
raise LogFollowError("Invalid offset; expected a non-negative integer.")
|
|
110
|
+
|
|
111
|
+
return cls(
|
|
112
|
+
log_path=_required_str(data.get("log_path"), field_name="log_path"),
|
|
113
|
+
offset=offset,
|
|
114
|
+
file_identity=FileIdentity.from_json(data=identity_value),
|
|
115
|
+
content_fingerprint=_optional_str(
|
|
116
|
+
data.get("content_fingerprint"),
|
|
117
|
+
field_name="content_fingerprint",
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class _OpenedLog:
|
|
124
|
+
"""Open binary file plus stat-derived metadata.
|
|
125
|
+
Keeping these together avoids stat/open race surprises.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
handle: BinaryIO
|
|
129
|
+
identity: FileIdentity
|
|
130
|
+
size: int
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class _ReadResult:
|
|
135
|
+
"""Complete lines read from a byte offset.
|
|
136
|
+
The next offset remains unchanged when the tail is partial.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
lines: tuple[str, ...]
|
|
140
|
+
next_offset: int
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class LogFollower:
|
|
144
|
+
"""Poll Player.log and emit only complete raw lines.
|
|
145
|
+
Offsets are persisted under Draftomen's app data directory.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
log_path: PathInput,
|
|
152
|
+
app_dir: PathInput | None = None,
|
|
153
|
+
poll_interval: float = POLL_INTERVAL_SECONDS,
|
|
154
|
+
previous_log_path: PathInput | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
if poll_interval <= 0:
|
|
157
|
+
raise ValueError("poll_interval must be greater than zero.")
|
|
158
|
+
|
|
159
|
+
self.log_path = Path(log_path).expanduser().resolve(strict=False)
|
|
160
|
+
self.app_dir = Path(app_data_dir() if app_dir is None else app_dir).expanduser()
|
|
161
|
+
self.poll_interval = poll_interval
|
|
162
|
+
self.previous_log_path = _default_previous_log_path(
|
|
163
|
+
log_path=self.log_path,
|
|
164
|
+
previous_log_path=previous_log_path,
|
|
165
|
+
)
|
|
166
|
+
self.offset_path = log_offset_path(log_path=self.log_path, app_dir=self.app_dir)
|
|
167
|
+
|
|
168
|
+
def poll(self) -> tuple[str, ...]:
|
|
169
|
+
"""Read complete lines appended since the persisted offset.
|
|
170
|
+
Rotation recovery reads any matching Player-prev.log tail first.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
state = self._load_state()
|
|
174
|
+
opened = _open_log(path=self.log_path)
|
|
175
|
+
if opened is None:
|
|
176
|
+
return ()
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
if state is None:
|
|
180
|
+
return self._read_current(opened=opened, offset=0)
|
|
181
|
+
|
|
182
|
+
self._ensure_state_matches_path(state=state)
|
|
183
|
+
if opened.identity != state.file_identity:
|
|
184
|
+
rotated_lines = self._read_rotated_tail(state=state)
|
|
185
|
+
current_lines = self._read_current(opened=opened, offset=0)
|
|
186
|
+
return rotated_lines + current_lines
|
|
187
|
+
|
|
188
|
+
if opened.size < state.offset:
|
|
189
|
+
return self._read_current(opened=opened, offset=0)
|
|
190
|
+
|
|
191
|
+
if not _emitted_prefix_matches(handle=opened.handle, state=state):
|
|
192
|
+
return self._read_current(opened=opened, offset=0)
|
|
193
|
+
|
|
194
|
+
return self._read_current(opened=opened, offset=state.offset)
|
|
195
|
+
finally:
|
|
196
|
+
opened.handle.close()
|
|
197
|
+
|
|
198
|
+
def follow(self) -> Iterator[str]:
|
|
199
|
+
"""Yield complete lines forever using the configured poll interval.
|
|
200
|
+
The iterator is intentionally raw and performs no log parsing.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
while True:
|
|
204
|
+
yield from self.poll()
|
|
205
|
+
time.sleep(self.poll_interval)
|
|
206
|
+
|
|
207
|
+
def scan_startup_files(self, *, include_previous: bool = True) -> tuple[str, ...]:
|
|
208
|
+
"""Scan existing rotated and current logs from the beginning.
|
|
209
|
+
The current log offset is advanced so later polling does not repeat it.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
lines: list[str] = []
|
|
213
|
+
if include_previous and self.previous_log_path is not None:
|
|
214
|
+
previous_opened = _open_log(path=self.previous_log_path)
|
|
215
|
+
if previous_opened is not None:
|
|
216
|
+
try:
|
|
217
|
+
previous_result = _read_complete_lines(
|
|
218
|
+
handle=previous_opened.handle,
|
|
219
|
+
offset=0,
|
|
220
|
+
)
|
|
221
|
+
lines.extend(previous_result.lines)
|
|
222
|
+
finally:
|
|
223
|
+
previous_opened.handle.close()
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
current_opened = _open_log(path=self.log_path)
|
|
227
|
+
except OSError:
|
|
228
|
+
return tuple(lines)
|
|
229
|
+
if current_opened is None:
|
|
230
|
+
return tuple(lines)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
current_result = _read_complete_lines(handle=current_opened.handle, offset=0)
|
|
234
|
+
lines.extend(current_result.lines)
|
|
235
|
+
self._save_state(
|
|
236
|
+
OffsetState(
|
|
237
|
+
log_path=str(self.log_path),
|
|
238
|
+
offset=current_result.next_offset,
|
|
239
|
+
file_identity=current_opened.identity,
|
|
240
|
+
content_fingerprint=_content_fingerprint(
|
|
241
|
+
handle=current_opened.handle,
|
|
242
|
+
offset=current_result.next_offset,
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
finally:
|
|
247
|
+
current_opened.handle.close()
|
|
248
|
+
|
|
249
|
+
return tuple(lines)
|
|
250
|
+
|
|
251
|
+
def _read_current(self, *, opened: _OpenedLog, offset: int) -> tuple[str, ...]:
|
|
252
|
+
result = _read_complete_lines(handle=opened.handle, offset=offset)
|
|
253
|
+
self._save_state(
|
|
254
|
+
OffsetState(
|
|
255
|
+
log_path=str(self.log_path),
|
|
256
|
+
offset=result.next_offset,
|
|
257
|
+
file_identity=opened.identity,
|
|
258
|
+
content_fingerprint=_content_fingerprint(
|
|
259
|
+
handle=opened.handle,
|
|
260
|
+
offset=result.next_offset,
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return result.lines
|
|
265
|
+
|
|
266
|
+
def _read_rotated_tail(self, *, state: OffsetState) -> tuple[str, ...]:
|
|
267
|
+
if self.previous_log_path is None:
|
|
268
|
+
return ()
|
|
269
|
+
|
|
270
|
+
previous_opened = _open_log(path=self.previous_log_path)
|
|
271
|
+
if previous_opened is None:
|
|
272
|
+
return ()
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
if previous_opened.identity != state.file_identity:
|
|
276
|
+
return ()
|
|
277
|
+
|
|
278
|
+
if previous_opened.size < state.offset:
|
|
279
|
+
return ()
|
|
280
|
+
|
|
281
|
+
return _read_complete_lines(
|
|
282
|
+
handle=previous_opened.handle,
|
|
283
|
+
offset=state.offset,
|
|
284
|
+
).lines
|
|
285
|
+
finally:
|
|
286
|
+
previous_opened.handle.close()
|
|
287
|
+
|
|
288
|
+
def _load_state(self) -> OffsetState | None:
|
|
289
|
+
if not self.offset_path.exists():
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
data = json.loads(self.offset_path.read_text(encoding="utf-8"))
|
|
294
|
+
except json.JSONDecodeError as error:
|
|
295
|
+
raise LogFollowError(
|
|
296
|
+
f"Malformed log follower state {self.offset_path}: {error}."
|
|
297
|
+
) from error
|
|
298
|
+
|
|
299
|
+
if not isinstance(data, Mapping):
|
|
300
|
+
raise LogFollowError(
|
|
301
|
+
f"Malformed log follower state {self.offset_path}: expected object."
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return OffsetState.from_json(data=data)
|
|
305
|
+
|
|
306
|
+
def _save_state(self, state: OffsetState) -> None:
|
|
307
|
+
self.offset_path.parent.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
payload = json.dumps(state.to_json(), indent=2, sort_keys=True)
|
|
309
|
+
with tempfile.NamedTemporaryFile(
|
|
310
|
+
"w",
|
|
311
|
+
delete=False,
|
|
312
|
+
dir=self.offset_path.parent,
|
|
313
|
+
encoding="utf-8",
|
|
314
|
+
) as temporary_file:
|
|
315
|
+
temporary_file.write(payload)
|
|
316
|
+
temporary_file.write("\n")
|
|
317
|
+
temporary_path = Path(temporary_file.name)
|
|
318
|
+
|
|
319
|
+
temporary_path.replace(self.offset_path)
|
|
320
|
+
|
|
321
|
+
def _ensure_state_matches_path(self, *, state: OffsetState) -> None:
|
|
322
|
+
if state.log_path != str(self.log_path):
|
|
323
|
+
raise LogFollowError(
|
|
324
|
+
"Log follower state path does not match followed log: "
|
|
325
|
+
f"{state.log_path!r} != {str(self.log_path)!r}."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def log_offset_path(*, log_path: PathInput, app_dir: PathInput | None = None) -> Path:
|
|
330
|
+
"""Return the persisted offset path for a followed log.
|
|
331
|
+
State files are keyed by a stable digest of the expanded log path.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
root = Path(app_data_dir() if app_dir is None else app_dir).expanduser()
|
|
335
|
+
resolved_log_path = Path(log_path).expanduser().resolve(strict=False)
|
|
336
|
+
digest = hashlib.sha256(str(resolved_log_path).encode("utf-8")).hexdigest()
|
|
337
|
+
return root / OFFSET_DIRECTORY_NAME / f"{digest[:STATE_HASH_LENGTH]}.json"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _open_log(*, path: Path) -> _OpenedLog | None:
|
|
341
|
+
try:
|
|
342
|
+
handle = path.open("rb")
|
|
343
|
+
except FileNotFoundError:
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
stat_result = os.fstat(handle.fileno())
|
|
348
|
+
except OSError:
|
|
349
|
+
handle.close()
|
|
350
|
+
raise
|
|
351
|
+
|
|
352
|
+
return _OpenedLog(
|
|
353
|
+
handle=handle,
|
|
354
|
+
identity=FileIdentity(
|
|
355
|
+
device=int(stat_result.st_dev),
|
|
356
|
+
inode=int(stat_result.st_ino),
|
|
357
|
+
),
|
|
358
|
+
size=int(stat_result.st_size),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def is_log_readable(*, path: PathInput) -> bool:
|
|
363
|
+
"""Return whether a Player.log path can currently be opened for reading.
|
|
364
|
+
Filesystem errors are reported as unavailable instead of escaping.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
with Path(path).expanduser().resolve(strict=False).open("rb"):
|
|
369
|
+
return True
|
|
370
|
+
except OSError:
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _read_complete_lines(*, handle: BinaryIO, offset: int) -> _ReadResult:
|
|
375
|
+
handle.seek(offset)
|
|
376
|
+
data = handle.read()
|
|
377
|
+
last_newline = data.rfind(b"\n")
|
|
378
|
+
if last_newline < 0:
|
|
379
|
+
return _ReadResult(lines=(), next_offset=offset)
|
|
380
|
+
|
|
381
|
+
complete_end = last_newline + 1
|
|
382
|
+
text = data[:complete_end].decode("utf-8", errors="replace")
|
|
383
|
+
return _ReadResult(
|
|
384
|
+
lines=tuple(text.splitlines()),
|
|
385
|
+
next_offset=offset + complete_end,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _emitted_prefix_matches(*, handle: BinaryIO, state: OffsetState) -> bool:
|
|
390
|
+
if state.content_fingerprint is None:
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
_content_fingerprint(handle=handle, offset=state.offset)
|
|
395
|
+
== state.content_fingerprint
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _content_fingerprint(*, handle: BinaryIO, offset: int) -> str:
|
|
400
|
+
hasher = hashlib.sha256()
|
|
401
|
+
hasher.update(str(offset).encode("ascii"))
|
|
402
|
+
hasher.update(b"\0")
|
|
403
|
+
if offset <= FINGERPRINT_SAMPLE_BYTES * 2:
|
|
404
|
+
handle.seek(0)
|
|
405
|
+
hasher.update(handle.read(offset))
|
|
406
|
+
return hasher.hexdigest()
|
|
407
|
+
|
|
408
|
+
handle.seek(0)
|
|
409
|
+
hasher.update(handle.read(FINGERPRINT_SAMPLE_BYTES))
|
|
410
|
+
handle.seek(offset - FINGERPRINT_SAMPLE_BYTES)
|
|
411
|
+
hasher.update(handle.read(FINGERPRINT_SAMPLE_BYTES))
|
|
412
|
+
return hasher.hexdigest()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _default_previous_log_path(
|
|
416
|
+
*,
|
|
417
|
+
log_path: Path,
|
|
418
|
+
previous_log_path: PathInput | None,
|
|
419
|
+
) -> Path | None:
|
|
420
|
+
if previous_log_path is not None:
|
|
421
|
+
return Path(previous_log_path).expanduser().resolve(strict=False)
|
|
422
|
+
|
|
423
|
+
if log_path.name != "Player.log":
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
return log_path.with_name("Player-prev.log")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _required_str(value: Any, *, field_name: str) -> str:
|
|
430
|
+
if not isinstance(value, str) or value == "":
|
|
431
|
+
raise LogFollowError(f"Missing or invalid {field_name}; expected non-empty string.")
|
|
432
|
+
|
|
433
|
+
return value
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _optional_str(value: Any, *, field_name: str) -> str | None:
|
|
437
|
+
if value is None:
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
return _required_str(value, field_name=field_name)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _required_int(value: Any, *, field_name: str) -> int:
|
|
444
|
+
if isinstance(value, bool):
|
|
445
|
+
raise LogFollowError(f"Missing or invalid {field_name}; expected integer.")
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
return int(value)
|
|
449
|
+
except (TypeError, ValueError) as error:
|
|
450
|
+
raise LogFollowError(f"Missing or invalid {field_name}; expected integer.") from error
|
|
451
|
+
|