mnemonic-proxy 0.3.2__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.
ctxproxy/check.py ADDED
@@ -0,0 +1,356 @@
1
+ # Author: Maurizio Verde — LastReload
2
+ """`mnemonic-proxy check`: is this setup ready? Reads the configuration the server would run with (same options:
3
+ --upstream, --config, --data, --port, --engine ...) and checks it against the engine that is actually running.
4
+
5
+ Every line: status, evidence, fix. Saved state is reported on three levels:
6
+ supported the engine can save/restore its state (llama-server started with --slot-save-path, Strata
7
+ session-files);
8
+ configured the proxy config turns the saved-state features on (slot_save, mask_anchor, autosave ...);
9
+ verified `--live` really saved and restored a state file, and found it in `slot_dir`.
10
+
11
+ `--live` also runs one short generation and one tool-call request on the engine. It refuses to run while the engine
12
+ slot is busy; the slot content is saved first and put back at the end, so an idle conversation is not lost.
13
+
14
+ Exit codes: 0 = ready, 2 = ready with warnings, 1 = not ready (at least one failure).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import json
20
+ import os
21
+ import socket
22
+ import sys
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+
27
+ from . import engines
28
+ from .core import Config
29
+ from .upstream import Upstream
30
+
31
+ OK, WARN, FAIL, INFO = "ok", "warn", "fail", "info"
32
+ ICON = {OK: "\u2705", WARN: "\u26a0\ufe0f ", FAIL: "\u274c", INFO: "\u2139\ufe0f "}
33
+ PLAIN = {OK: "[ OK ]", WARN: "[WARN]", FAIL: "[FAIL]", INFO: "[INFO]"}
34
+ SAVED_STATE = ("slot_save", "mask_anchor", "autosave", "seal_experimental", "kv_archive")
35
+
36
+
37
+ class Report:
38
+ def __init__(self):
39
+ self.items: list[dict] = []
40
+
41
+ def add(self, status, name, evidence, fix=""):
42
+ self.items.append({"status": status, "check": name, "evidence": evidence, "fix": fix})
43
+
44
+ @property
45
+ def exit_code(self) -> int:
46
+ st = {i["status"] for i in self.items}
47
+ return 1 if FAIL in st else 2 if WARN in st else 0
48
+
49
+
50
+ def _get(url, timeout=5.0):
51
+ """-> (status, parsed JSON or text) ; status 0 = not reachable."""
52
+ try:
53
+ with urllib.request.urlopen(url, timeout=timeout) as r:
54
+ data, st = r.read(), r.status
55
+ except urllib.error.HTTPError as e:
56
+ data, st = e.read(), e.code
57
+ except Exception as e: # noqa: BLE001
58
+ return 0, repr(e)
59
+ try:
60
+ return st, json.loads(data)
61
+ except ValueError:
62
+ return st, data.decode("utf-8", "replace")[:300]
63
+
64
+
65
+ def _port_state(host, port):
66
+ """-> "free" | "proxy" (a mnemonic-proxy answers there) | "busy"."""
67
+ s = socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET, socket.SOCK_STREAM)
68
+ try:
69
+ s.bind((host, port))
70
+ return "free", None
71
+ except OSError:
72
+ pass
73
+ finally:
74
+ s.close()
75
+ st, body = _get("http://%s:%d/v1/engine" % (host if ":" not in host else "[%s]" % host, port), 3)
76
+ if st == 200 and isinstance(body, dict) and "kind" in body:
77
+ return "proxy", body
78
+ return "busy", None
79
+
80
+
81
+ def _writable(d):
82
+ try:
83
+ os.makedirs(d, exist_ok=True)
84
+ p = os.path.join(d, ".mnemonic-check-%d" % os.getpid())
85
+ with open(p, "w", encoding="utf-8") as f:
86
+ f.write("x")
87
+ os.remove(p)
88
+ return True, ""
89
+ except OSError as e:
90
+ return False, str(e)
91
+
92
+
93
+ def run_checks(a, raw_cfg: dict, cfg: Config, live: bool = False) -> Report:
94
+ rep = Report()
95
+ explicit = set(raw_cfg)
96
+ # --- config file
97
+ if a.config:
98
+ names = {f.name for f in dataclasses.fields(Config)}
99
+ unknown = sorted(k for k in raw_cfg if k not in names)
100
+ if unknown:
101
+ rep.add(WARN, "config", "%s: unknown fields ignored: %s" % (a.config, ", ".join(unknown)),
102
+ "check the spelling against the Configuration table in the README")
103
+ else:
104
+ rep.add(OK, "config", "%s: %d fields, all known" % (a.config, len(raw_cfg)))
105
+ else:
106
+ rep.add(INFO, "config", "no --config: defaults (saved-state features off)",
107
+ "for llama-server use examples/config.llama-server.json")
108
+ # --- python
109
+ if cfg.kv_archive and sys.version_info < (3, 14):
110
+ rep.add(FAIL, "python", "kv_archive needs Python >= 3.14, this is %d.%d" % sys.version_info[:2],
111
+ 'set "kv_archive": false or use Python 3.14')
112
+ else:
113
+ rep.add(OK, "python", "Python %d.%d" % sys.version_info[:2])
114
+ # --- data folder
115
+ ok, err = _writable(a.data)
116
+ rep.add(OK if ok else FAIL, "data folder", "%s %s" % (os.path.abspath(a.data), "is writable" if ok else
117
+ "is not writable: " + err),
118
+ "" if ok else "choose another --data folder or fix its permissions")
119
+ # --- port
120
+ state, body = _port_state(a.host, a.port)
121
+ if state == "free":
122
+ rep.add(OK, "port", "%s:%d is free" % (a.host, a.port))
123
+ elif state == "proxy":
124
+ rep.add(INFO, "port", "%s:%d: a mnemonic-proxy is already running there (engine %s)"
125
+ % (a.host, a.port, (body or {}).get("kind")), "fine if it is this one; otherwise use another --port")
126
+ else:
127
+ rep.add(FAIL, "port", "%s:%d is taken by another program" % (a.host, a.port),
128
+ "pick a free --port (and the same port in the client's base URL)")
129
+ # --- upstream
130
+ up = Upstream(a.upstream, a.api_key, timeout=10)
131
+ st_h, h = _get(up.base + "/health", 5)
132
+ st_m, models = _get(up.base + "/v1/models", 5)
133
+ if st_h == 0 and st_m == 0:
134
+ rep.add(FAIL, "engine reachable", "%s does not answer (%s)" % (a.upstream, h),
135
+ "start the engine first (llama-server ... --port N) and pass the same URL to --upstream")
136
+ _reminder(rep)
137
+ return rep
138
+ rep.add(OK, "engine reachable", "%s answers (/health %s, /v1/models %s)" % (a.upstream, st_h or "-", st_m or "-"))
139
+ try:
140
+ eng = engines.detect(up, cfg.engine, int(cfg.slot_id or 0))
141
+ except ValueError as e:
142
+ rep.add(FAIL, "engine type", str(e), 'use "engine": "auto", "strata", "llama.cpp", "ds4" or "openai"')
143
+ return rep
144
+ how = "detected" if eng.detected else "forced by config/--engine"
145
+ rep.add(OK if eng.detected or cfg.engine != "auto" else WARN, "engine type",
146
+ "%s (%s)%s" % (eng.kind, how, ("; notes: " + "; ".join(eng.notes)) if eng.notes else ""),
147
+ "" if eng.kind != "openai" or cfg.engine != "auto" else
148
+ "llama-server, Strata and ds4-server are recognised; anything else runs in base mode (no saved state)")
149
+ # --- model loaded
150
+ if st_h == 503:
151
+ rep.add(FAIL, "model loaded", "/health answers 503: the engine is still loading the model",
152
+ "wait until llama-server prints 'server is listening', then run check again")
153
+ else:
154
+ ids = [m.get("id") for m in (models.get("data") or [])] if isinstance(models, dict) else []
155
+ if eng.kind == "strata":
156
+ s = engines._get_json(up, "/v1/status") or {}
157
+ loaded = bool(s.get("loaded"))
158
+ rep.add(OK if loaded else FAIL, "model loaded", "Strata /v1/status loaded=%s" % s.get("loaded"),
159
+ "" if loaded else "load a model in Strata")
160
+ elif ids or eng.model:
161
+ rep.add(OK, "model loaded", "model: %s" % (eng.model or ", ".join(map(str, ids[:3]))))
162
+ else:
163
+ rep.add(WARN, "model loaded", "the engine did not list a model (/v1/models %s)" % st_m,
164
+ "check that the engine has a model loaded")
165
+ # --- window vs n_ctx
166
+ if eng.n_ctx:
167
+ if "window" in explicit:
168
+ if cfg.window > eng.n_ctx:
169
+ rep.add(FAIL, "context window", "config window=%d > engine n_ctx=%d: prompts would overflow"
170
+ % (cfg.window, eng.n_ctx), "remove \"window\" from the config (taken from n_ctx) or start "
171
+ "the engine with -c %d" % cfg.window)
172
+ else:
173
+ rep.add(OK if cfg.window == eng.n_ctx else WARN, "context window",
174
+ "config window=%d, engine n_ctx=%d" % (cfg.window, eng.n_ctx),
175
+ "" if cfg.window == eng.n_ctx else "window smaller than n_ctx wastes context")
176
+ elif eng.kind in ("llama.cpp", "ds4"): # come engines.apply: finestra da n_ctx / context_length
177
+ probe = Config.from_dict(raw_cfg)
178
+ engines.apply(probe, eng, None, explicit, log=None)
179
+ rep.add(OK, "context window", "window from engine n_ctx=%d; thresholds scaled: mask at %d -> %d tokens, "
180
+ "protected tail %d" % (eng.n_ctx, probe.mask_trigger, probe.mask_target,
181
+ probe.keep_recent_tokens))
182
+ bad = [k for k in ("notes_max_tokens", "response_floor", "tail_max", "reserve")
183
+ if k in explicit and getattr(probe, k) * 4 > eng.n_ctx]
184
+ if bad:
185
+ rep.add(WARN, "context window", "fields written for a bigger window: %s (n_ctx %d)"
186
+ % (", ".join("%s=%d" % (k, getattr(probe, k)) for k in bad), eng.n_ctx),
187
+ "remove them from the config: unset thresholds scale with n_ctx")
188
+ else:
189
+ rep.add(INFO, "context window", "engine n_ctx=%d, proxy window=%d" % (eng.n_ctx, cfg.window))
190
+ elif eng.kind == "openai":
191
+ rep.add(WARN if "window" not in explicit else OK, "context window",
192
+ "generic engine: n_ctx unknown, proxy window=%d" % cfg.window,
193
+ "" if "window" in explicit else "set \"window\" in the config to the engine's real context size")
194
+ # --- minimum window (0.3.0): the server refuses to start below it, so this is a failure, not a warning
195
+ for evidence, fix in engines.window_problems(cfg, eng, explicit):
196
+ rep.add(FAIL, "context window", evidence + " (the proxy refuses to start)", fix)
197
+ # --- saved state: supported / configured / verified
198
+ configured = [k for k in SAVED_STATE if getattr(cfg, k, False)]
199
+ if eng.slot_save and configured:
200
+ rep.add(OK, "saved state", "supported by the engine, configured in the proxy (%s)" % ", ".join(configured),
201
+ "" if live else "run `mnemonic-proxy check --live ...` to verify a real save/restore")
202
+ elif eng.slot_save:
203
+ rep.add(WARN, "saved state", "supported by the engine, disabled in the proxy config",
204
+ "use examples/config.llama-server.json (slot_save, mask_anchor, autosave) with slot_dir set")
205
+ elif configured:
206
+ hint = {"llama.cpp": "start llama-server with --slot-save-path DIR (same folder as slot_dir)",
207
+ "strata": "official Strata has no session files: build the session-files branch (README, Engines), "
208
+ "or use examples/config.official-strata.json",
209
+ "openai": "this engine cannot save state: drop the saved-state fields (the proxy turns them off)",
210
+ "ds4": "ds4-server saves its own state with --kv-disk-dir: drop the saved-state fields "
211
+ "(use examples/config.ds4.json)"}
212
+ rep.add(WARN, "saved state", "configured in the proxy (%s) but not supported by the engine: the proxy will "
213
+ "turn them off" % ", ".join(configured), hint.get(eng.kind, ""))
214
+ elif eng.kind == "ds4":
215
+ rep.add(INFO, "saved state", "ds4-server keeps its own state (start it with --kv-disk-dir DIR); the proxy "
216
+ "does not save or restore it")
217
+ else:
218
+ rep.add(INFO, "saved state", "not supported by the engine and not configured: base mode (masking, archive, "
219
+ "recall, segments)")
220
+ if eng.slot_save and configured:
221
+ if not cfg.slot_dir:
222
+ rep.add(WARN, "slot_dir", "not set: old anchors and autosaves are never cleaned up, kv_archive off",
223
+ "set \"slot_dir\" to the engine's --slot-save-path folder")
224
+ elif not os.path.isdir(cfg.slot_dir):
225
+ rep.add(FAIL, "slot_dir", "%s does not exist" % cfg.slot_dir,
226
+ "create it and start the engine with --slot-save-path pointing to it (same folder)")
227
+ else:
228
+ ok, err = _writable(cfg.slot_dir)
229
+ rep.add(OK if ok else FAIL, "slot_dir", "%s exists%s" % (cfg.slot_dir, ", writable" if ok else
230
+ ", NOT writable: " + err),
231
+ "" if live else "check --live verifies it is the engine's --slot-save-path")
232
+ if live:
233
+ _live(rep, up, eng, cfg)
234
+ _reminder(rep)
235
+ return rep
236
+
237
+
238
+ def _live(rep, up, eng, cfg):
239
+ up.timeout = 600
240
+ up.set_engine(eng, cfg.slot_dir)
241
+ st = engines.normalize_status(eng, up) or {}
242
+ if (st.get("activity") or {}).get("in_flight"):
243
+ rep.add(FAIL, "live", "the engine slot is busy right now", "run check --live when no agent is working")
244
+ return
245
+ tag = "mnemonic-check-%d" % os.getpid()
246
+ backup = None
247
+ if eng.slot_save:
248
+ try:
249
+ up.slot("save", tag + "-backup.bin")
250
+ backup = tag + "-backup.bin"
251
+ except Exception as e: # noqa: BLE001
252
+ rep.add(FAIL, "live: backup", "could not save the current slot first: %s" % str(e)[:200],
253
+ "not touching the engine; fix saved state first")
254
+ return
255
+ try:
256
+ t0 = time.time()
257
+ r = up.chat({"model": "local-model", "max_tokens": 16, "temperature": 0,
258
+ "messages": [{"role": "user", "content": "Reply with the single word: ready"}]})
259
+ txt = ((r.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
260
+ rep.add(OK, "live: generation", "%.1f s, reply %r" % (time.time() - t0, txt.strip()[:60]))
261
+ t0 = time.time()
262
+ tool = {"type": "function", "function": {
263
+ "name": "get_time", "description": "Return the current time in a city",
264
+ "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}
265
+ r = up.chat({"model": "local-model", "max_tokens": 128, "temperature": 0, "tools": [tool],
266
+ "messages": [{"role": "user", "content": "What time is it in Rome? Use the tool."}]})
267
+ calls = ((r.get("choices") or [{}])[0].get("message") or {}).get("tool_calls") or []
268
+ good = False
269
+ if calls:
270
+ try:
271
+ good = bool(json.loads(calls[0]["function"]["arguments"]).get("city"))
272
+ except (ValueError, KeyError, TypeError, AttributeError):
273
+ good = False
274
+ rep.add(OK if good else WARN, "live: tool call",
275
+ ("%.1f s, %s(%s)" % (time.time() - t0, calls[0]["function"]["name"],
276
+ calls[0]["function"]["arguments"])) if calls else
277
+ "the model answered without calling the tool", "" if good else
278
+ "llama-server: start with --jinja (chat template with tools); try a model with tool support")
279
+ if eng.slot_save:
280
+ fn = tag + ".bin"
281
+ t0 = time.time()
282
+ s = up.slot("save", fn)
283
+ r2 = up.slot("restore", fn)
284
+ n_s, n_r = s.get("n_saved"), r2.get("n_restored")
285
+ rep.add(OK if n_s and n_s == n_r else FAIL, "live: save/restore",
286
+ "saved %s tokens, restored %s, %.2f s" % (n_s, n_r, time.time() - t0),
287
+ "" if n_s and n_s == n_r else "the engine could not restore its own state file")
288
+ p = os.path.join(cfg.slot_dir, fn) if cfg.slot_dir else ""
289
+ if p and os.path.exists(p):
290
+ rep.add(OK, "live: slot_dir", "the state file appeared in %s: slot_dir matches --slot-save-path"
291
+ % cfg.slot_dir)
292
+ os.remove(p)
293
+ elif cfg.slot_dir:
294
+ rep.add(FAIL, "live: slot_dir", "the engine saved %s but it is not in %s" % (fn, cfg.slot_dir),
295
+ "slot_dir must be the same folder as llama-server --slot-save-path (and on the same host)")
296
+ except Exception as e: # noqa: BLE001
297
+ rep.add(FAIL, "live", "request failed: %s" % str(e)[:300], "see the engine log")
298
+ finally:
299
+ if backup:
300
+ try:
301
+ up.slot("restore", backup)
302
+ if cfg.slot_dir and os.path.exists(os.path.join(cfg.slot_dir, backup)):
303
+ os.remove(os.path.join(cfg.slot_dir, backup))
304
+ rep.add(INFO, "live", "the slot content from before the check was put back")
305
+ except Exception as e: # noqa: BLE001
306
+ rep.add(WARN, "live", "could not put back the previous slot content: %s" % str(e)[:200],
307
+ "the next request re-reads its prompt once")
308
+
309
+
310
+ def _reminder(rep):
311
+ rep.add(INFO, "client", "turn off the client's own auto-compaction (pi: \"compaction\": {\"enabled\": false}; "
312
+ "Claude Code: DISABLE_AUTO_COMPACT=1), otherwise two context managers fight over the same history")
313
+
314
+
315
+ def render(rep: Report, out=sys.stdout):
316
+ enc = (getattr(out, "encoding", None) or "ascii").lower()
317
+ try:
318
+ "\u2705\u26a0\ufe0f\u274c\u2139\ufe0f".encode(enc)
319
+ icons = ICON
320
+ except (UnicodeEncodeError, LookupError):
321
+ icons = PLAIN
322
+ for i in rep.items:
323
+ print("%s %-18s %s" % (icons[i["status"]], i["check"], i["evidence"]), file=out)
324
+ if i["fix"]:
325
+ print(" %-18s -> %s" % ("", i["fix"]), file=out)
326
+ word = {0: "READY", 2: "READY with warnings", 1: "NOT READY"}[rep.exit_code]
327
+ print("\n%s (exit %d)" % (word, rep.exit_code), file=out)
328
+
329
+
330
+ def main(argv=None):
331
+ from .server import build_parser, load_config
332
+ ap = build_parser(prog="mnemonic-proxy check",
333
+ description="Check the setup the proxy would run with (same options as the server). "
334
+ "Exit 0 = ready, 2 = ready with warnings, 1 = not ready.")
335
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
336
+ ap.add_argument("--live", action="store_true",
337
+ help="also run a short generation, a tool call and a save/restore on the engine "
338
+ "(refused while the slot is busy; the slot content is put back)")
339
+ a = ap.parse_args(argv)
340
+ try:
341
+ raw_cfg, cfg = load_config(a)
342
+ except (OSError, ValueError) as e:
343
+ rep = Report()
344
+ rep.add(FAIL, "config", "%s: %s" % (a.config, e), "fix the JSON file")
345
+ else:
346
+ rep = run_checks(a, raw_cfg, cfg, live=a.live)
347
+ if a.json:
348
+ print(json.dumps({"ready": rep.exit_code != 1, "exit_code": rep.exit_code, "checks": rep.items},
349
+ ensure_ascii=False, indent=1))
350
+ else:
351
+ render(rep)
352
+ sys.exit(rep.exit_code)
353
+
354
+
355
+ if __name__ == "__main__":
356
+ main()
ctxproxy/compat.py ADDED
@@ -0,0 +1,91 @@
1
+ # Author: Maurizio Verde — LastReload
2
+ """Portability helpers (Linux / macOS / native Windows), standard library only.
3
+
4
+ - `lock_fd` / `unlock_fd`: exclusive, blocking lock on an open file descriptor. POSIX: `fcntl.flock` (unchanged
5
+ behaviour); Windows: `msvcrt.locking` on the first byte, retried until it is granted.
6
+ - `console_safe`: a Windows console (cp1252, cp850...) cannot encode characters like ⟪ 📌 🗑; printing them raised
7
+ UnicodeEncodeError. Non-UTF-8 stdout/stderr switch to `errors="backslashreplace"`; UTF-8 streams are untouched.
8
+ - `sqlite_ro_uri`: `file:` URI for a read-only sqlite connection that also works with `C:\\...` paths.
9
+ - `bad_name`: characters that cannot appear in a single file name on this OS.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ import time
16
+
17
+ WINDOWS = os.name == "nt"
18
+
19
+ if WINDOWS: # pragma: no cover - exercised on the windows-latest CI runner
20
+ import msvcrt
21
+
22
+ def lock_fd(fd: int) -> None:
23
+ os.lseek(fd, 0, os.SEEK_SET)
24
+ while True:
25
+ try:
26
+ msvcrt.locking(fd, msvcrt.LK_LOCK, 1) # LK_LOCK itself retries for ~10 s, then OSError
27
+ return
28
+ except OSError:
29
+ time.sleep(0.05)
30
+
31
+ def unlock_fd(fd: int) -> None:
32
+ os.lseek(fd, 0, os.SEEK_SET)
33
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
34
+ else:
35
+ import fcntl
36
+
37
+ def lock_fd(fd: int) -> None:
38
+ fcntl.flock(fd, fcntl.LOCK_EX)
39
+
40
+ def unlock_fd(fd: int) -> None:
41
+ fcntl.flock(fd, fcntl.LOCK_UN)
42
+
43
+
44
+ def console_safe(*streams) -> None:
45
+ """Never crash on a character the console code page cannot encode (UTF-8 streams are left as they are)."""
46
+ for s in streams or (sys.stdout, sys.stderr):
47
+ enc = (getattr(s, "encoding", None) or "").lower().replace("-", "").replace("_", "")
48
+ if enc in ("utf8", "utf8sig") or not hasattr(s, "reconfigure"):
49
+ continue
50
+ try:
51
+ s.reconfigure(errors="backslashreplace")
52
+ except (ValueError, OSError):
53
+ pass
54
+
55
+
56
+ def sqlite_ro_uri(path: str) -> str:
57
+ """Read-only sqlite URI. POSIX: `file:/abs/path?mode=ro` (as before). Windows: `file:///C:/abs/path?mode=ro`."""
58
+ p = os.path.abspath(path)
59
+ if WINDOWS:
60
+ p = "/" + p.replace("\\", "/")
61
+ if WINDOWS:
62
+ return "file://%s?mode=ro" % p.replace("%", "%25").replace("?", "%3f").replace("#", "%23")
63
+ return "file:%s?mode=ro" % p.replace("%", "%25").replace("?", "%3f").replace("#", "%23")
64
+
65
+
66
+ def replace(src: str, dst: str, tries: int = 50) -> None:
67
+ """os.replace. On Windows a destination briefly open in another process (a reader of last_request.json, an
68
+ antivirus scan) makes it fail with PermissionError: retry for up to ~2.5 s. POSIX: a single os.replace."""
69
+ if not WINDOWS:
70
+ os.replace(src, dst)
71
+ return
72
+ for i in range(tries): # pragma: no cover - Windows only
73
+ try:
74
+ os.replace(src, dst)
75
+ return
76
+ except PermissionError:
77
+ if i == tries - 1:
78
+ raise
79
+ time.sleep(0.05)
80
+
81
+
82
+ O_BINARY = getattr(os, "O_BINARY", 0) # Windows: os.open is text mode (LF -> CRLF on write) without it
83
+
84
+
85
+ def bad_name(name: str) -> bool:
86
+ """True if `name` is not a plain file name here (path separators; on Windows also `:` and reserved chars)."""
87
+ if "/" in name or name.startswith("."):
88
+ return True
89
+ if WINDOWS:
90
+ return any(c in name for c in '\\:*?"<>|') or name.rstrip(" .") != name
91
+ return False