fvpnctl 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/PKG-INFO +30 -4
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/README.md +29 -3
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/pyproject.toml +1 -1
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/__init__.py +1 -1
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/cli.py +103 -17
- fvpnctl-0.2.4/src/fvpnctl/history.py +129 -0
- fvpnctl-0.2.4/src/fvpnctl/spinner.py +215 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_cli.py +79 -2
- fvpnctl-0.2.4/tests/test_history.py +91 -0
- fvpnctl-0.2.4/tests/test_spinner.py +168 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/uv.lock +1 -1
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/.github/workflows/ci.yml +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/.gitignore +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/.pre-commit-config.yaml +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/LICENSE +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/contrib/com.fvpnctl.headless.plist +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/docs/how-it-works.md +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/docs/superpowers/specs/2026-06-20-fortivpn-cli-via-cdp-design.md +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/cdp.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/controller.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/errors.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/keychain.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/launcher.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/monitor.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/manual/test_live.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_cdp.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_controller.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_errors.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_keychain.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_launcher.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_monitor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fvpnctl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Control your FortiClient VPN from the macOS command line.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mpasternak/fortivpn-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/mpasternak/fortivpn-cli
|
|
@@ -224,6 +224,9 @@ Global options:
|
|
|
224
224
|
- `--verbose` / `--quiet` — progress messages. Verbose is **on by default** and writes
|
|
225
225
|
progress to **stderr**; `--quiet` silences it. Either way `stdout` carries only the
|
|
226
226
|
machine-readable result, so `--json` output and shell pipelines are byte-identical.
|
|
227
|
+
While `connect` waits for the tunnel (it polls once a second), verbose mode shows a
|
|
228
|
+
Braille throbber with an elapsed-seconds counter on a TTY, falls back to a single
|
|
229
|
+
static line when stderr is piped or redirected, and shows nothing under `--quiet`.
|
|
227
230
|
|
|
228
231
|
`list` and `status` additionally accept `--json` for machine-readable output.
|
|
229
232
|
|
|
@@ -333,15 +336,38 @@ tray for you (without quitting the app) — best effort, so a failure to hide ne
|
|
|
333
336
|
otherwise-successful connect. Pass `--show-window` to keep the window up. (This only applies
|
|
334
337
|
in the waited path; with `--no-wait` the popup happens after the command returns.)
|
|
335
338
|
|
|
336
|
-
|
|
339
|
+
While it waits, `connect` shows live progress on a TTY (verbose mode). The **first** time you
|
|
340
|
+
connect a profile there is no timing history, so it shows an indeterminate Braille throbber
|
|
341
|
+
with an elapsed-seconds counter. fvpnctl records how long each successful connect took (per
|
|
342
|
+
profile, the last 10 runs, under `~/.local/state/fvpnctl/` — overridable with
|
|
343
|
+
`$FVPNCTL_STATE_DIR`), so on **subsequent** connects it shows a **progress bar** that fills
|
|
344
|
+
toward the average of those past durations:
|
|
337
345
|
|
|
338
|
-
|
|
346
|
+
```console
|
|
347
|
+
$ fvpnctl connect office
|
|
348
|
+
Connecting profile office… [█████████████░░░░░░░░░░░] 54% 7s / ~13s
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The bar never claims 100% until the tunnel is actually up, and the `7s / ~13s` tail stays
|
|
352
|
+
honest if a connect runs longer than usual. Piped/redirected output falls back to a single
|
|
353
|
+
static line, and `--quiet` shows nothing.
|
|
354
|
+
|
|
355
|
+
### `fvpnctl disconnect [profile]`
|
|
356
|
+
|
|
357
|
+
Disconnect a tunnel. The **profile argument is optional**: with no argument `disconnect`
|
|
358
|
+
tears down whatever is connected right now (it reads the active connection from the daemon),
|
|
359
|
+
so you don't have to remember which profile is up. Pass a name to target a specific profile.
|
|
339
360
|
|
|
340
361
|
```console
|
|
341
|
-
$ fvpnctl disconnect
|
|
362
|
+
$ fvpnctl disconnect # disconnect the active tunnel
|
|
363
|
+
DISCONNECTED office
|
|
364
|
+
$ fvpnctl disconnect office # or name it explicitly
|
|
342
365
|
DISCONNECTED office
|
|
343
366
|
```
|
|
344
367
|
|
|
368
|
+
If nothing is connected, `disconnect` says so and exits `0` (the desired end state already
|
|
369
|
+
holds), so it is safe to run unconditionally in scripts.
|
|
370
|
+
|
|
345
371
|
### `fvpnctl ip`
|
|
346
372
|
|
|
347
373
|
Print just the assigned tunnel IP — handy in scripts. Exits `1` with a message on stderr if
|
|
@@ -200,6 +200,9 @@ Global options:
|
|
|
200
200
|
- `--verbose` / `--quiet` — progress messages. Verbose is **on by default** and writes
|
|
201
201
|
progress to **stderr**; `--quiet` silences it. Either way `stdout` carries only the
|
|
202
202
|
machine-readable result, so `--json` output and shell pipelines are byte-identical.
|
|
203
|
+
While `connect` waits for the tunnel (it polls once a second), verbose mode shows a
|
|
204
|
+
Braille throbber with an elapsed-seconds counter on a TTY, falls back to a single
|
|
205
|
+
static line when stderr is piped or redirected, and shows nothing under `--quiet`.
|
|
203
206
|
|
|
204
207
|
`list` and `status` additionally accept `--json` for machine-readable output.
|
|
205
208
|
|
|
@@ -309,15 +312,38 @@ tray for you (without quitting the app) — best effort, so a failure to hide ne
|
|
|
309
312
|
otherwise-successful connect. Pass `--show-window` to keep the window up. (This only applies
|
|
310
313
|
in the waited path; with `--no-wait` the popup happens after the command returns.)
|
|
311
314
|
|
|
312
|
-
|
|
315
|
+
While it waits, `connect` shows live progress on a TTY (verbose mode). The **first** time you
|
|
316
|
+
connect a profile there is no timing history, so it shows an indeterminate Braille throbber
|
|
317
|
+
with an elapsed-seconds counter. fvpnctl records how long each successful connect took (per
|
|
318
|
+
profile, the last 10 runs, under `~/.local/state/fvpnctl/` — overridable with
|
|
319
|
+
`$FVPNCTL_STATE_DIR`), so on **subsequent** connects it shows a **progress bar** that fills
|
|
320
|
+
toward the average of those past durations:
|
|
313
321
|
|
|
314
|
-
|
|
322
|
+
```console
|
|
323
|
+
$ fvpnctl connect office
|
|
324
|
+
Connecting profile office… [█████████████░░░░░░░░░░░] 54% 7s / ~13s
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
The bar never claims 100% until the tunnel is actually up, and the `7s / ~13s` tail stays
|
|
328
|
+
honest if a connect runs longer than usual. Piped/redirected output falls back to a single
|
|
329
|
+
static line, and `--quiet` shows nothing.
|
|
330
|
+
|
|
331
|
+
### `fvpnctl disconnect [profile]`
|
|
332
|
+
|
|
333
|
+
Disconnect a tunnel. The **profile argument is optional**: with no argument `disconnect`
|
|
334
|
+
tears down whatever is connected right now (it reads the active connection from the daemon),
|
|
335
|
+
so you don't have to remember which profile is up. Pass a name to target a specific profile.
|
|
315
336
|
|
|
316
337
|
```console
|
|
317
|
-
$ fvpnctl disconnect
|
|
338
|
+
$ fvpnctl disconnect # disconnect the active tunnel
|
|
339
|
+
DISCONNECTED office
|
|
340
|
+
$ fvpnctl disconnect office # or name it explicitly
|
|
318
341
|
DISCONNECTED office
|
|
319
342
|
```
|
|
320
343
|
|
|
344
|
+
If nothing is connected, `disconnect` says so and exits `0` (the desired end state already
|
|
345
|
+
holds), so it is safe to run unconditionally in scripts.
|
|
346
|
+
|
|
321
347
|
### `fvpnctl ip`
|
|
322
348
|
|
|
323
349
|
Print just the assigned tunnel IP — handy in scripts. Exits `1` with a message on stderr if
|
|
@@ -62,16 +62,19 @@ import argparse
|
|
|
62
62
|
import json
|
|
63
63
|
import os
|
|
64
64
|
import sys
|
|
65
|
+
import time
|
|
65
66
|
|
|
66
|
-
from fvpnctl import launcher, monitor
|
|
67
|
+
from fvpnctl import history, launcher, monitor
|
|
67
68
|
from fvpnctl.cdp import CDPSession
|
|
68
69
|
from fvpnctl.controller import FortiVPN
|
|
69
70
|
from fvpnctl.errors import CDPEvaluateError, FortiError, NotRunningError
|
|
71
|
+
from fvpnctl.spinner import ProgressBar, Spinner
|
|
70
72
|
|
|
71
|
-
# IPsec ``ipsec_state``
|
|
72
|
-
# / design spec 4.2). The
|
|
73
|
-
#
|
|
73
|
+
# IPsec ``ipsec_state`` values the CLI needs as sentinels (docs/how-it-works.md
|
|
74
|
+
# section 2 / design spec 4.2). The controller owns the full enum; the CLI only
|
|
75
|
+
# compares against "tunnel up" and "tunnel fully down".
|
|
74
76
|
_CONNECTED = 2
|
|
77
|
+
_DISCONNECTED = 0
|
|
75
78
|
|
|
76
79
|
# Connection type for v1. Only IPsec is supported (SSL is out of scope); the CLI
|
|
77
80
|
# always queries/derives with this type. See design spec section 2.
|
|
@@ -214,8 +217,23 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
214
217
|
)
|
|
215
218
|
p_connect.set_defaults(func=_cmd_connect)
|
|
216
219
|
|
|
217
|
-
p_disconnect = sub.add_parser(
|
|
218
|
-
|
|
220
|
+
p_disconnect = sub.add_parser(
|
|
221
|
+
"disconnect",
|
|
222
|
+
parents=[common],
|
|
223
|
+
help="Disconnect the active tunnel (or a named profile).",
|
|
224
|
+
description=(
|
|
225
|
+
"Disconnect the currently active tunnel. The profile argument is "
|
|
226
|
+
"optional: with no argument it disconnects whatever is connected now "
|
|
227
|
+
"(read from the daemon's reported connection); pass a name to target a "
|
|
228
|
+
"specific profile."
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
p_disconnect.add_argument(
|
|
232
|
+
"profile",
|
|
233
|
+
nargs="?",
|
|
234
|
+
default=None,
|
|
235
|
+
help="The profile (connection_name) to disconnect (default: the active one).",
|
|
236
|
+
)
|
|
219
237
|
p_disconnect.set_defaults(func=_cmd_disconnect)
|
|
220
238
|
|
|
221
239
|
p_ip = sub.add_parser(
|
|
@@ -364,21 +382,48 @@ def _cmd_connect(fvpnctl: FortiVPN, args: argparse.Namespace) -> int:
|
|
|
364
382
|
and returns; otherwise, on a CONNECTED terminal state, it fetches the VPN IP
|
|
365
383
|
and prints ``CONNECTED <profile> <ip>``. The password is resolved inside the
|
|
366
384
|
controller (from the Keychain) and is never accepted or printed here.
|
|
385
|
+
|
|
386
|
+
The waited path blocks in the controller's once-a-second poll loop while the
|
|
387
|
+
daemon negotiates, which otherwise looks frozen — so it is wrapped in a live
|
|
388
|
+
indicator on stderr (a single static line when piped, nothing under
|
|
389
|
+
``--quiet``; gated by ``_VERBOSE`` exactly like :func:`report`). When this
|
|
390
|
+
profile has connect-time history a :class:`~fvpnctl.spinner.ProgressBar`
|
|
391
|
+
fills toward the mean past duration (the ETA); with no history yet it falls
|
|
392
|
+
back to the indeterminate :class:`~fvpnctl.spinner.Spinner` throbber. Each
|
|
393
|
+
successful waited connect's wall-clock time is recorded so the *next* connect
|
|
394
|
+
can show the bar. ``--no-wait`` returns immediately, so it keeps the plain
|
|
395
|
+
progress line instead.
|
|
367
396
|
"""
|
|
368
397
|
wait = not args.no_wait
|
|
369
|
-
|
|
370
|
-
state = fvpnctl.connect(
|
|
371
|
-
args.profile,
|
|
372
|
-
username=args.user,
|
|
373
|
-
wait=wait,
|
|
374
|
-
timeout=args.timeout,
|
|
375
|
-
)
|
|
398
|
+
msg = f"Connecting profile {args.profile}…"
|
|
376
399
|
|
|
377
400
|
if not wait:
|
|
401
|
+
report(msg)
|
|
402
|
+
fvpnctl.connect(args.profile, username=args.user, wait=False, timeout=args.timeout)
|
|
378
403
|
print(f"connecting {args.profile} ...")
|
|
379
404
|
return 0
|
|
380
405
|
|
|
406
|
+
eta = _connect_eta(args.profile)
|
|
407
|
+
indicator = (
|
|
408
|
+
ProgressBar(msg, eta, stream=sys.stderr, enabled=_VERBOSE)
|
|
409
|
+
if eta is not None
|
|
410
|
+
else Spinner(msg, stream=sys.stderr, enabled=_VERBOSE)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
start = time.monotonic()
|
|
414
|
+
with indicator:
|
|
415
|
+
state = fvpnctl.connect(
|
|
416
|
+
args.profile,
|
|
417
|
+
username=args.user,
|
|
418
|
+
wait=True,
|
|
419
|
+
timeout=args.timeout,
|
|
420
|
+
)
|
|
421
|
+
elapsed = time.monotonic() - start
|
|
422
|
+
|
|
381
423
|
if state.ipsec_state == _CONNECTED:
|
|
424
|
+
# Record the duration only on a successful waited connect — that is what
|
|
425
|
+
# the user actually waited through and what the next ETA should average.
|
|
426
|
+
_record_connect_time(args.profile, elapsed)
|
|
382
427
|
ip = fvpnctl.connection_ip(args.profile, _IPSEC)
|
|
383
428
|
vpn_ip = ip.get("vpn_ip", "")
|
|
384
429
|
# FortiClient pops its window on connect even under --hide-gui; hide it
|
|
@@ -386,7 +431,7 @@ def _cmd_connect(fvpnctl: FortiVPN, args: argparse.Namespace) -> int:
|
|
|
386
431
|
# waited path: with --no-wait the popup happens after we return.
|
|
387
432
|
if not args.show_window:
|
|
388
433
|
_hide_window_best_effort(fvpnctl)
|
|
389
|
-
report(f"Connected: {vpn_ip}")
|
|
434
|
+
report(f"Connected: {vpn_ip} (in {elapsed:.0f}s)")
|
|
390
435
|
print(f"CONNECTED {args.profile} {vpn_ip}")
|
|
391
436
|
else:
|
|
392
437
|
# Reached here only with wait=True and a non-connected terminal state the
|
|
@@ -396,10 +441,51 @@ def _cmd_connect(fvpnctl: FortiVPN, args: argparse.Namespace) -> int:
|
|
|
396
441
|
return 0
|
|
397
442
|
|
|
398
443
|
|
|
444
|
+
def _connect_eta(profile: str) -> float | None:
|
|
445
|
+
"""Mean past connect time for ``profile`` (None ⇒ show the throbber instead).
|
|
446
|
+
|
|
447
|
+
Reads :func:`history.average`. The history is a best-effort UX aid: a missing,
|
|
448
|
+
corrupt, or unreadable store must never block a connect, so any
|
|
449
|
+
``OSError``/``ValueError`` is reported (when verbose) and treated as "no ETA"
|
|
450
|
+
— the CLI then uses the indeterminate spinner.
|
|
451
|
+
"""
|
|
452
|
+
try:
|
|
453
|
+
return history.average(profile)
|
|
454
|
+
except (OSError, ValueError) as e:
|
|
455
|
+
report(f"(connect-time history unavailable: {e})")
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _record_connect_time(profile: str, seconds: float) -> None:
|
|
460
|
+
"""Persist this connect's duration; report and continue if it can't be saved.
|
|
461
|
+
|
|
462
|
+
A failed write (corrupt store, read-only dir) must not undo a connect that
|
|
463
|
+
already succeeded, so the error is surfaced (when verbose) and swallowed here.
|
|
464
|
+
"""
|
|
465
|
+
try:
|
|
466
|
+
history.record(profile, seconds)
|
|
467
|
+
except (OSError, ValueError) as e:
|
|
468
|
+
report(f"(could not save connect time: {e})")
|
|
469
|
+
|
|
470
|
+
|
|
399
471
|
def _cmd_disconnect(fvpnctl: FortiVPN, args: argparse.Namespace) -> int:
|
|
400
|
-
"""``fvpnctl disconnect
|
|
401
|
-
|
|
402
|
-
|
|
472
|
+
"""``fvpnctl disconnect [profile]`` — tear down a tunnel and confirm.
|
|
473
|
+
|
|
474
|
+
With no ``profile`` it disconnects whatever is active: it reads ``state()``
|
|
475
|
+
and uses the daemon's reported ``connection_name``. If nothing is active it
|
|
476
|
+
says so and returns 0 — the desired end state (disconnected) already holds, so
|
|
477
|
+
``fvpnctl disconnect`` is idempotent. An explicit ``profile`` is still honored
|
|
478
|
+
(and still works even if the daemon reports a different/empty active name).
|
|
479
|
+
"""
|
|
480
|
+
profile = args.profile
|
|
481
|
+
if profile is None:
|
|
482
|
+
state = fvpnctl.state()
|
|
483
|
+
if state.ipsec_state == _DISCONNECTED or not state.name:
|
|
484
|
+
print("No active tunnel to disconnect.", file=sys.stderr)
|
|
485
|
+
return 0
|
|
486
|
+
profile = state.name
|
|
487
|
+
fvpnctl.disconnect(profile)
|
|
488
|
+
print(f"DISCONNECTED {profile}")
|
|
403
489
|
return 0
|
|
404
490
|
|
|
405
491
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Connect-time history — per-profile durations that feed the progress bar.
|
|
2
|
+
|
|
3
|
+
What this is
|
|
4
|
+
------------
|
|
5
|
+
``fvpnctl connect`` blocks while the daemon negotiates the tunnel. The first time
|
|
6
|
+
a profile is connected there is nothing to estimate against, so the CLI shows an
|
|
7
|
+
indeterminate Braille throbber (``spinner.Spinner``). Every *successful* waited
|
|
8
|
+
connect records its wall-clock duration here; on the next connect the CLI reads
|
|
9
|
+
back the mean of the recent samples and shows a **determinate progress bar**
|
|
10
|
+
(``spinner.ProgressBar``) that fills toward that ETA.
|
|
11
|
+
|
|
12
|
+
Why per-profile, why a recent window
|
|
13
|
+
------------------------------------
|
|
14
|
+
Different gateways have very different handshake times, so durations are keyed by
|
|
15
|
+
``connection_name``. Only the last :data:`_HISTORY_LEN` samples are kept and
|
|
16
|
+
averaged, so the estimate tracks *current* network conditions instead of being
|
|
17
|
+
dragged by months-old measurements.
|
|
18
|
+
|
|
19
|
+
Where it lives
|
|
20
|
+
--------------
|
|
21
|
+
A small JSON file under the per-user XDG *state* dir (these are derived numbers
|
|
22
|
+
fvpnctl can always rebuild — state, not config). The location is resolved by
|
|
23
|
+
:func:`state_dir`; ``$FVPNCTL_STATE_DIR`` overrides everything (the test suite
|
|
24
|
+
points it at a tmp dir so it never touches real user state).
|
|
25
|
+
|
|
26
|
+
Error policy (project rule: never swallow silently)
|
|
27
|
+
---------------------------------------------------
|
|
28
|
+
A *missing* file is the normal first-run case and yields an empty history — not
|
|
29
|
+
an error. A *corrupt* file makes :func:`load`/:func:`average` raise ``ValueError``
|
|
30
|
+
so the CLI can surface it (``cli._connect_eta`` reports it and falls back to the
|
|
31
|
+
throbber). :func:`record` additionally self-heals a corrupt file rather than
|
|
32
|
+
failing a connect that just succeeded — the one place a narrow catch is justified.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
# Keep at most this many recent measurements per profile; the progress-bar ETA is
|
|
40
|
+
# their mean. A recent window (not all-time) so the estimate adapts to changing
|
|
41
|
+
# network conditions.
|
|
42
|
+
_HISTORY_LEN = 10
|
|
43
|
+
|
|
44
|
+
_FILENAME = "connect-durations.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def state_dir() -> Path:
|
|
48
|
+
"""Resolve the directory holding fvpnctl's persistent state.
|
|
49
|
+
|
|
50
|
+
Order: ``$FVPNCTL_STATE_DIR`` (explicit override; used by the tests), then
|
|
51
|
+
``$XDG_STATE_HOME/fvpnctl``, else ``~/.local/state/fvpnctl``.
|
|
52
|
+
"""
|
|
53
|
+
override = os.environ.get("FVPNCTL_STATE_DIR")
|
|
54
|
+
if override:
|
|
55
|
+
return Path(override).expanduser()
|
|
56
|
+
xdg = os.environ.get("XDG_STATE_HOME")
|
|
57
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "state"
|
|
58
|
+
return base / "fvpnctl"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _path() -> Path:
|
|
62
|
+
return state_dir() / _FILENAME
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load() -> dict[str, list]:
|
|
66
|
+
"""Load the per-profile duration history.
|
|
67
|
+
|
|
68
|
+
:returns: the raw ``{profile: [seconds, ...]}`` mapping (``{}`` when no file
|
|
69
|
+
exists yet — the normal first-run case).
|
|
70
|
+
:raises ValueError: the file exists but is not valid JSON, or is valid JSON of
|
|
71
|
+
the wrong shape (``json.JSONDecodeError`` is a ``ValueError`` subclass).
|
|
72
|
+
Callers surface this rather than mask it.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
raw = _path().read_text(encoding="utf-8")
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
return {}
|
|
78
|
+
data = json.loads(raw)
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
raise ValueError(f"{_path()} is not a JSON object")
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def average(profile: str) -> float | None:
|
|
85
|
+
"""Mean recorded connect duration for ``profile`` — the progress-bar ETA.
|
|
86
|
+
|
|
87
|
+
:returns: the mean of the stored samples, or ``None`` when the profile has no
|
|
88
|
+
usable history yet. Non-numeric stray entries are ignored defensively so a
|
|
89
|
+
single bad value never breaks the estimate.
|
|
90
|
+
:raises ValueError: if the underlying store is corrupt (propagated from
|
|
91
|
+
:func:`load`).
|
|
92
|
+
"""
|
|
93
|
+
samples = [float(x) for x in load().get(profile, []) if isinstance(x, (int, float))]
|
|
94
|
+
if not samples:
|
|
95
|
+
return None
|
|
96
|
+
return sum(samples) / len(samples)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def record(profile: str, seconds: float) -> None:
|
|
100
|
+
"""Append one connect duration for ``profile`` and persist (keep last N).
|
|
101
|
+
|
|
102
|
+
:raises OSError: if the store cannot be written (caller decides whether to
|
|
103
|
+
surface or ignore — a failed write must never undo a successful connect).
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
data = load()
|
|
107
|
+
except ValueError:
|
|
108
|
+
# A corrupt history file is non-critical: reset rather than fail the
|
|
109
|
+
# connect that just succeeded. This measurement re-creates a valid file.
|
|
110
|
+
data = {}
|
|
111
|
+
samples = data.get(profile)
|
|
112
|
+
if not isinstance(samples, list):
|
|
113
|
+
samples = []
|
|
114
|
+
samples.append(round(float(seconds), 1))
|
|
115
|
+
data[profile] = samples[-_HISTORY_LEN:]
|
|
116
|
+
save(data)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def save(data: dict) -> None:
|
|
120
|
+
"""Atomically write the history JSON (``mkdir -p`` + temp file + rename).
|
|
121
|
+
|
|
122
|
+
The temp-file-then-``replace`` keeps the file readable at all times: a crash
|
|
123
|
+
mid-write leaves the previous good file intact rather than a truncated one.
|
|
124
|
+
"""
|
|
125
|
+
path = _path()
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
128
|
+
tmp.write_text(json.dumps(data), encoding="utf-8")
|
|
129
|
+
tmp.replace(path)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Connect progress indicators — a Braille throbber and an ETA progress bar.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
``fvpnctl connect <profile>`` (without ``--no-wait``) blocks in
|
|
6
|
+
:meth:`FortiVPN._wait_for_connection`, which polls ``getConnectionState`` once a
|
|
7
|
+
second for up to ``--timeout`` seconds while the daemon negotiates the IPsec
|
|
8
|
+
tunnel. That loop is silent, so a slow handshake looks like a frozen process.
|
|
9
|
+
This module paints live feedback so the user can see the wait is *working*:
|
|
10
|
+
|
|
11
|
+
* :class:`Spinner` — an indeterminate Braille throbber + elapsed seconds, used
|
|
12
|
+
the first time a profile is connected (no timing history to estimate against).
|
|
13
|
+
* :class:`ProgressBar` — a determinate bar that fills toward an ETA, used once
|
|
14
|
+
``history`` has recorded past connect times for the profile (the ETA is their
|
|
15
|
+
mean; see ``history.average``).
|
|
16
|
+
|
|
17
|
+
Both share the lifecycle in :class:`_Animated`; they differ only in the single
|
|
18
|
+
line they paint each tick (:meth:`_Animated._line`).
|
|
19
|
+
|
|
20
|
+
How they stay out of the controller's way
|
|
21
|
+
-----------------------------------------
|
|
22
|
+
The poll loop lives in ``controller.py`` and must stay UI-agnostic (the same
|
|
23
|
+
controller/transport/render split the rest of the codebase follows — see
|
|
24
|
+
``monitor.py``). So the indicator does **not** reach into the loop: the CLI wraps
|
|
25
|
+
the blocking ``connect()`` call in the context manager, the indicator animates on
|
|
26
|
+
a daemon thread while the main thread does the CDP I/O, and ``__exit__`` tears
|
|
27
|
+
the animation down. The controller is untouched.
|
|
28
|
+
|
|
29
|
+
Three modes, chosen from the stream + verbosity (:func:`select_mode`)
|
|
30
|
+
---------------------------------------------------------------------
|
|
31
|
+
* **off** — ``--quiet`` (``enabled=False``): write nothing at all.
|
|
32
|
+
* **static** — verbose but the stream is not a TTY (piped / redirected): one
|
|
33
|
+
plain ``message`` line, no ANSI and no ``\\r`` spam, so logs stay readable.
|
|
34
|
+
* **animate**— verbose on a real TTY: hide the cursor, paint the first frame
|
|
35
|
+
synchronously (instant feedback, and one deterministic frame for tests), then
|
|
36
|
+
animate on a background thread until ``__exit__``.
|
|
37
|
+
|
|
38
|
+
stderr, never stdout
|
|
39
|
+
--------------------
|
|
40
|
+
Like ``cli.report``, indicators write to **stderr** so stdout stays the
|
|
41
|
+
machine-readable channel (``CONNECTED <profile> <ip>``, ``--json``, pipelines).
|
|
42
|
+
``__exit__`` wipes the line and restores the cursor *before* the caller prints
|
|
43
|
+
its result/error, so success and failure both start on a clean line.
|
|
44
|
+
|
|
45
|
+
Pure vs. I/O
|
|
46
|
+
------------
|
|
47
|
+
:func:`render_frame`, :func:`render_bar` and :func:`select_mode` are pure (data
|
|
48
|
+
in, string out) and unit-tested directly; only :class:`_Animated` touches a
|
|
49
|
+
stream, a thread, and the clock.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
import sys
|
|
53
|
+
import threading
|
|
54
|
+
import time
|
|
55
|
+
|
|
56
|
+
# Braille "dots" spinner — ten frames that read as a smooth rotation. Zero
|
|
57
|
+
# dependencies (project rule): the frames are just literal Unicode here.
|
|
58
|
+
_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
59
|
+
|
|
60
|
+
# ANSI control sequences (defined locally so this module stays standalone, like
|
|
61
|
+
# ``monitor.py`` keeps its own copy). Named so the I/O below reads as intent.
|
|
62
|
+
_HIDE_CURSOR = "\x1b[?25l"
|
|
63
|
+
_SHOW_CURSOR = "\x1b[?25h"
|
|
64
|
+
_CLEAR_EOL = "\x1b[K"
|
|
65
|
+
|
|
66
|
+
# Progress-bar geometry and glyphs.
|
|
67
|
+
_BAR_W = 24
|
|
68
|
+
_BAR_FILL = "█"
|
|
69
|
+
_BAR_EMPTY = "░"
|
|
70
|
+
# Never paint a full bar while the connect is still running — 100% is reserved
|
|
71
|
+
# for "actually finished". The bar saturates just below full and the elapsed/ETA
|
|
72
|
+
# tail keeps counting, so a slower-than-usual connect reads as "nearly there,
|
|
73
|
+
# overdue" rather than a bar stuck at 100%.
|
|
74
|
+
_BAR_CAP = 0.99
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def render_frame(frame: str, message: str, elapsed: int) -> str:
|
|
78
|
+
"""Build one throbber line: ``\\r<frame> <message> <elapsed>s`` + clear-to-EOL.
|
|
79
|
+
|
|
80
|
+
Leads with ``\\r`` so each frame overwrites the previous in place, and ends
|
|
81
|
+
with :data:`_CLEAR_EOL` so a shrinking line never leaves stale trailing
|
|
82
|
+
characters. Pure — no I/O.
|
|
83
|
+
"""
|
|
84
|
+
return f"\r{frame} {message} {elapsed:d}s{_CLEAR_EOL}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_bar(message: str, elapsed: float, eta: float, *, width: int = _BAR_W) -> str:
|
|
88
|
+
"""Build one progress-bar line filled to ``elapsed/eta`` (capped below 100%).
|
|
89
|
+
|
|
90
|
+
The fraction is clamped to :data:`_BAR_CAP` and at least one cell is always
|
|
91
|
+
left empty while running, so the bar never claims completion before the
|
|
92
|
+
connect actually returns. The ``<elapsed>s / ~<eta>s`` tail stays honest when
|
|
93
|
+
a connect overruns its estimate (e.g. ``41s / ~30s`` at 99%). Pure — no I/O.
|
|
94
|
+
"""
|
|
95
|
+
frac = 0.0 if eta <= 0 else min(elapsed / eta, _BAR_CAP)
|
|
96
|
+
filled = int(frac * width)
|
|
97
|
+
if frac < 1.0:
|
|
98
|
+
filled = min(filled, width - 1) # keep one empty cell until truly done
|
|
99
|
+
bar = _BAR_FILL * filled + _BAR_EMPTY * (width - filled)
|
|
100
|
+
pct = int(frac * 100)
|
|
101
|
+
tail = f"{int(elapsed)}s / ~{int(round(eta))}s"
|
|
102
|
+
return f"\r{message} [{bar}] {pct:>2d}% {tail}{_CLEAR_EOL}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def select_mode(stream, *, enabled: bool) -> str:
|
|
106
|
+
"""Choose ``off``/``static``/``animate`` from verbosity + the stream.
|
|
107
|
+
|
|
108
|
+
``enabled`` is the CLI's verbose flag: ``False`` (``--quiet``) → ``off``
|
|
109
|
+
regardless of the stream. Otherwise a real TTY animates and anything else
|
|
110
|
+
(a pipe, a file, a captured test buffer) gets the single static line.
|
|
111
|
+
"""
|
|
112
|
+
if not enabled:
|
|
113
|
+
return "off"
|
|
114
|
+
isatty = getattr(stream, "isatty", None)
|
|
115
|
+
if isatty and isatty():
|
|
116
|
+
return "animate"
|
|
117
|
+
return "static"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _Animated:
|
|
121
|
+
"""Shared lifecycle for the connect indicators: mode, thread, cursor, cleanup.
|
|
122
|
+
|
|
123
|
+
Subclasses implement :meth:`_line` to render one frame for a given elapsed
|
|
124
|
+
time and tick count. In ``animate`` mode a daemon thread repaints every
|
|
125
|
+
``interval`` seconds; ``__exit__`` stops it, joins it, wipes the line and
|
|
126
|
+
restores the cursor — including when the wrapped block raises (a
|
|
127
|
+
``ConnectTimeout``/``ConnectFailed`` must still leave a clean terminal).
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, message: str, *, stream=None, enabled: bool = True, interval: float = 0.1):
|
|
131
|
+
self._message = message
|
|
132
|
+
self._stream = stream if stream is not None else sys.stderr
|
|
133
|
+
self._interval = interval
|
|
134
|
+
self._mode = select_mode(self._stream, enabled=enabled)
|
|
135
|
+
self._stop = threading.Event()
|
|
136
|
+
self._thread: threading.Thread | None = None
|
|
137
|
+
self._start: float | None = None
|
|
138
|
+
self._tick = 0
|
|
139
|
+
|
|
140
|
+
def _line(self, elapsed: float, tick: int) -> str:
|
|
141
|
+
"""Return the full ``\\r…`` line to paint this frame (subclass hook)."""
|
|
142
|
+
raise NotImplementedError
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> "_Animated":
|
|
145
|
+
if self._mode == "off":
|
|
146
|
+
return self
|
|
147
|
+
if self._mode == "static":
|
|
148
|
+
# Pipe/log path: one plain line, no animation.
|
|
149
|
+
self._stream.write(self._message + "\n")
|
|
150
|
+
self._stream.flush()
|
|
151
|
+
return self
|
|
152
|
+
# animate: hide the cursor and paint the first frame right away so there
|
|
153
|
+
# is instant feedback (and one deterministic frame for the tests) before
|
|
154
|
+
# the background thread takes over.
|
|
155
|
+
self._start = time.monotonic()
|
|
156
|
+
self._stream.write(_HIDE_CURSOR)
|
|
157
|
+
self._paint()
|
|
158
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
159
|
+
self._thread.start()
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def _paint(self) -> None:
|
|
163
|
+
"""Write one frame for the current elapsed time + tick, then flush."""
|
|
164
|
+
elapsed = time.monotonic() - self._start if self._start is not None else 0.0
|
|
165
|
+
self._stream.write(self._line(elapsed, self._tick))
|
|
166
|
+
self._stream.flush()
|
|
167
|
+
self._tick += 1
|
|
168
|
+
|
|
169
|
+
def _run(self) -> None:
|
|
170
|
+
"""Background loop: repaint every ``interval`` until stopped.
|
|
171
|
+
|
|
172
|
+
Uses ``Event.wait(interval)`` rather than ``time.sleep`` so ``__exit__``
|
|
173
|
+
setting the stop event ends the wait *immediately* — teardown never
|
|
174
|
+
delays the caller by up to ``interval``.
|
|
175
|
+
"""
|
|
176
|
+
while not self._stop.wait(self._interval):
|
|
177
|
+
self._paint()
|
|
178
|
+
|
|
179
|
+
def __exit__(self, *exc) -> bool:
|
|
180
|
+
if self._mode != "animate":
|
|
181
|
+
return False
|
|
182
|
+
self._stop.set()
|
|
183
|
+
if self._thread is not None:
|
|
184
|
+
self._thread.join()
|
|
185
|
+
# Wipe the line and bring the cursor back so the caller's next write
|
|
186
|
+
# (result on stdout / error on stderr) starts on a clean line.
|
|
187
|
+
self._stream.write("\r" + _CLEAR_EOL + _SHOW_CURSOR)
|
|
188
|
+
self._stream.flush()
|
|
189
|
+
return False # never suppress the wrapped block's exception
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Spinner(_Animated):
|
|
193
|
+
"""Indeterminate Braille throbber — used when there is no ETA to estimate."""
|
|
194
|
+
|
|
195
|
+
def _line(self, elapsed: float, tick: int) -> str:
|
|
196
|
+
return render_frame(_FRAMES[tick % len(_FRAMES)], self._message, int(elapsed))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ProgressBar(_Animated):
|
|
200
|
+
"""Determinate progress bar driven by ``eta`` (mean of past connect times)."""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
message: str,
|
|
205
|
+
eta: float,
|
|
206
|
+
*,
|
|
207
|
+
stream=None,
|
|
208
|
+
enabled: bool = True,
|
|
209
|
+
interval: float = 0.1,
|
|
210
|
+
):
|
|
211
|
+
super().__init__(message, stream=stream, enabled=enabled, interval=interval)
|
|
212
|
+
self._eta = eta
|
|
213
|
+
|
|
214
|
+
def _line(self, elapsed: float, tick: int) -> str:
|
|
215
|
+
return render_bar(self._message, elapsed, self._eta)
|
|
@@ -142,13 +142,15 @@ class _FakeProfile:
|
|
|
142
142
|
|
|
143
143
|
|
|
144
144
|
@pytest.fixture(autouse=True)
|
|
145
|
-
def patched(monkeypatch):
|
|
145
|
+
def patched(monkeypatch, tmp_path):
|
|
146
146
|
"""Swap the two seams and reset per-test state.
|
|
147
147
|
|
|
148
148
|
Every test gets fresh ``FakeSession.instances`` and ``FakeController.config``
|
|
149
149
|
so assertions about construction args / routed calls never bleed between
|
|
150
150
|
tests. The ``FORTI_CDP_PORT`` env var is cleared so the default-port tests
|
|
151
|
-
are not perturbed by the developer's environment.
|
|
151
|
+
are not perturbed by the developer's environment. ``FVPNCTL_STATE_DIR`` is
|
|
152
|
+
pointed at a throwaway ``tmp_path`` so the connect-time history the CLI now
|
|
153
|
+
reads/writes never touches the developer's real state dir.
|
|
152
154
|
"""
|
|
153
155
|
FakeSession.instances = []
|
|
154
156
|
FakeController.config = {}
|
|
@@ -156,6 +158,8 @@ def patched(monkeypatch):
|
|
|
156
158
|
monkeypatch.setattr(cli, "CDPSession", FakeSession)
|
|
157
159
|
monkeypatch.setattr(cli, "FortiVPN", FakeController)
|
|
158
160
|
monkeypatch.delenv("FORTI_CDP_PORT", raising=False)
|
|
161
|
+
monkeypatch.setenv("FVPNCTL_STATE_DIR", str(tmp_path / "state"))
|
|
162
|
+
monkeypatch.delenv("XDG_STATE_HOME", raising=False)
|
|
159
163
|
return FakeController
|
|
160
164
|
|
|
161
165
|
|
|
@@ -343,6 +347,30 @@ def test_disconnect_routes_and_prints(capsys):
|
|
|
343
347
|
assert out == "DISCONNECTED office"
|
|
344
348
|
|
|
345
349
|
|
|
350
|
+
def test_disconnect_no_arg_uses_active_profile(capsys):
|
|
351
|
+
# No profile given → read state(), disconnect whatever the daemon reports.
|
|
352
|
+
FakeController.config["state"] = FakeState(ipsec_state=2, name="apoz")
|
|
353
|
+
FakeController.config["disconnect"] = None
|
|
354
|
+
|
|
355
|
+
rc = cli.main(["disconnect"])
|
|
356
|
+
|
|
357
|
+
assert rc == 0
|
|
358
|
+
disconnect_calls = [c for c in FakeController.last.calls if c[0] == "disconnect"]
|
|
359
|
+
assert disconnect_calls[0][1][0] == "apoz"
|
|
360
|
+
assert capsys.readouterr().out.strip() == "DISCONNECTED apoz"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_disconnect_no_arg_when_nothing_connected(capsys):
|
|
364
|
+
# Idempotent: nothing active → say so, never call DisconnectTunnel, exit 0.
|
|
365
|
+
FakeController.config["state"] = FakeState(ipsec_state=0, name="", state_label="DISCONNECTED")
|
|
366
|
+
|
|
367
|
+
rc = cli.main(["disconnect"])
|
|
368
|
+
|
|
369
|
+
assert rc == 0
|
|
370
|
+
assert [c for c in FakeController.last.calls if c[0] == "disconnect"] == []
|
|
371
|
+
assert "no active" in capsys.readouterr().err.lower()
|
|
372
|
+
|
|
373
|
+
|
|
346
374
|
def test_connect_hides_window_by_default():
|
|
347
375
|
FakeController.config["connect"] = FakeState(ipsec_state=2, name="office")
|
|
348
376
|
FakeController.config["connection_ip"] = {"vpn_ip": "172.16.200.2"}
|
|
@@ -388,6 +416,55 @@ def test_connect_hide_failure_does_not_fail_connect(capsys):
|
|
|
388
416
|
assert "CONNECTED" in capsys.readouterr().out
|
|
389
417
|
|
|
390
418
|
|
|
419
|
+
def test_connect_records_duration_for_next_time():
|
|
420
|
+
# A successful waited connect persists its duration (isolated tmp state dir),
|
|
421
|
+
# so the NEXT connect can show a progress bar.
|
|
422
|
+
from fvpnctl import history
|
|
423
|
+
|
|
424
|
+
FakeController.config["connect"] = FakeState(ipsec_state=2, name="apoz")
|
|
425
|
+
FakeController.config["connection_ip"] = {"vpn_ip": "10.0.0.2"}
|
|
426
|
+
|
|
427
|
+
assert history.average("apoz") is None # nothing recorded yet
|
|
428
|
+
rc = cli.main(["connect", "apoz"])
|
|
429
|
+
|
|
430
|
+
assert rc == 0
|
|
431
|
+
assert history.average("apoz") is not None # one measurement now stored
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_connect_with_history_succeeds_via_progress_bar_branch(capsys):
|
|
435
|
+
# Pre-seeded history → eta is not None → the ProgressBar branch is taken.
|
|
436
|
+
# Output is captured (non-TTY) so the bar renders as its static line; the
|
|
437
|
+
# connect must still succeed and print the machine-readable result.
|
|
438
|
+
from fvpnctl import history
|
|
439
|
+
|
|
440
|
+
history.record("apoz", 8.0)
|
|
441
|
+
FakeController.config["connect"] = FakeState(ipsec_state=2, name="apoz")
|
|
442
|
+
FakeController.config["connection_ip"] = {"vpn_ip": "10.0.0.2"}
|
|
443
|
+
|
|
444
|
+
rc = cli.main(["connect", "apoz"])
|
|
445
|
+
|
|
446
|
+
assert rc == 0
|
|
447
|
+
assert "CONNECTED apoz 10.0.0.2" in capsys.readouterr().out
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_connect_history_failure_does_not_break_connect(monkeypatch, capsys):
|
|
451
|
+
# A corrupt/unreadable history store must never block a connect: the ETA read
|
|
452
|
+
# degrades to the throbber and the connect still succeeds.
|
|
453
|
+
from fvpnctl import history
|
|
454
|
+
|
|
455
|
+
def boom(_profile):
|
|
456
|
+
raise ValueError("corrupt history")
|
|
457
|
+
|
|
458
|
+
monkeypatch.setattr(history, "average", boom)
|
|
459
|
+
FakeController.config["connect"] = FakeState(ipsec_state=2, name="apoz")
|
|
460
|
+
FakeController.config["connection_ip"] = {"vpn_ip": "10.0.0.2"}
|
|
461
|
+
|
|
462
|
+
rc = cli.main(["connect", "apoz"])
|
|
463
|
+
|
|
464
|
+
assert rc == 0
|
|
465
|
+
assert "CONNECTED apoz" in capsys.readouterr().out
|
|
466
|
+
|
|
467
|
+
|
|
391
468
|
def test_hide_window_command_routes():
|
|
392
469
|
rc = cli.main(["hide-window"])
|
|
393
470
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Tests for the connect-time history (``fvpnctl.history``).
|
|
2
|
+
|
|
3
|
+
CI-safe and isolated: every test points ``$FVPNCTL_STATE_DIR`` at a per-test
|
|
4
|
+
``tmp_path`` (autouse fixture), so nothing reads or writes the developer's real
|
|
5
|
+
``~/.local/state/fvpnctl``. The store is a tiny JSON file, so the tests exercise
|
|
6
|
+
it end to end (record → reload → average) plus the resolution and resilience
|
|
7
|
+
edges (env overrides, missing file, corrupt file, recent-window trimming).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from fvpnctl import history
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def isolated_state(monkeypatch, tmp_path):
|
|
19
|
+
monkeypatch.setenv("FVPNCTL_STATE_DIR", str(tmp_path / "state"))
|
|
20
|
+
# XDG must not leak in and override the explicit dir under test.
|
|
21
|
+
monkeypatch.delenv("XDG_STATE_HOME", raising=False)
|
|
22
|
+
return tmp_path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_average_is_none_on_first_run():
|
|
26
|
+
# No file yet → empty history → no ETA (CLI falls back to the throbber).
|
|
27
|
+
assert history.average("apoz") is None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_record_then_average_round_trips():
|
|
31
|
+
history.record("apoz", 12.0)
|
|
32
|
+
history.record("apoz", 8.0)
|
|
33
|
+
assert history.average("apoz") == pytest.approx(10.0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_history_is_per_profile():
|
|
37
|
+
history.record("apoz", 30.0)
|
|
38
|
+
history.record("office", 4.0)
|
|
39
|
+
assert history.average("apoz") == pytest.approx(30.0)
|
|
40
|
+
assert history.average("office") == pytest.approx(4.0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_only_recent_n_samples_are_kept_and_averaged():
|
|
44
|
+
for _ in range(history._HISTORY_LEN):
|
|
45
|
+
history.record("apoz", 2.0)
|
|
46
|
+
# An 11th very different sample evicts the oldest; the window stays at N and
|
|
47
|
+
# the mean reflects only the recent window, not all-time.
|
|
48
|
+
history.record("apoz", 24.0)
|
|
49
|
+
samples = history.load()["apoz"]
|
|
50
|
+
assert len(samples) == history._HISTORY_LEN
|
|
51
|
+
expected = (2.0 * (history._HISTORY_LEN - 1) + 24.0) / history._HISTORY_LEN
|
|
52
|
+
assert history.average("apoz") == pytest.approx(expected)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_state_dir_prefers_explicit_override(monkeypatch, tmp_path):
|
|
56
|
+
monkeypatch.setenv("FVPNCTL_STATE_DIR", str(tmp_path / "explicit"))
|
|
57
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg"))
|
|
58
|
+
assert history.state_dir() == (tmp_path / "explicit")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_state_dir_falls_back_to_xdg_then_home(monkeypatch, tmp_path):
|
|
62
|
+
monkeypatch.delenv("FVPNCTL_STATE_DIR", raising=False)
|
|
63
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg"))
|
|
64
|
+
assert history.state_dir() == (tmp_path / "xdg" / "fvpnctl")
|
|
65
|
+
monkeypatch.delenv("XDG_STATE_HOME", raising=False)
|
|
66
|
+
monkeypatch.setattr(history.Path, "home", classmethod(lambda cls: tmp_path / "home"))
|
|
67
|
+
assert history.state_dir() == (tmp_path / "home" / ".local" / "state" / "fvpnctl")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_corrupt_file_makes_average_raise():
|
|
71
|
+
path = history._path()
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
path.write_text("{not json", encoding="utf-8")
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
history.average("apoz")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_record_self_heals_a_corrupt_file():
|
|
79
|
+
path = history._path()
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
path.write_text("garbage", encoding="utf-8")
|
|
82
|
+
# record() must not propagate the corruption: it resets and writes a valid file.
|
|
83
|
+
history.record("apoz", 5.0)
|
|
84
|
+
assert json.loads(path.read_text(encoding="utf-8")) == {"apoz": [5.0]}
|
|
85
|
+
assert history.average("apoz") == pytest.approx(5.0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_save_is_atomic_no_tmp_left_behind():
|
|
89
|
+
history.record("apoz", 3.0)
|
|
90
|
+
leftovers = list(history.state_dir().glob("*.tmp"))
|
|
91
|
+
assert leftovers == []
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Tests for the connect throbber (``fvpnctl.spinner``).
|
|
2
|
+
|
|
3
|
+
CI-safe: no real terminal, no real sleeping that the suite depends on. The pure
|
|
4
|
+
helpers (:func:`spinner.render_frame`, :func:`spinner.select_mode`) are tested
|
|
5
|
+
directly. The :class:`spinner.Spinner` context manager is driven over a fake
|
|
6
|
+
stream whose ``isatty()`` is scripted, so each of the three modes
|
|
7
|
+
(``off``/``static``/``animate``) is exercised deterministically:
|
|
8
|
+
|
|
9
|
+
* ``off`` — ``enabled=False`` (the ``--quiet`` path): writes nothing.
|
|
10
|
+
* ``static``— a non-TTY stream (pipe/log): one plain line, no ANSI, no ``\\r``.
|
|
11
|
+
* ``animate``— a TTY stream: hides the cursor, paints frame 0 *synchronously* on
|
|
12
|
+
enter (so at least one frame is deterministic without waiting on the thread),
|
|
13
|
+
and on exit wipes the line and restores the cursor.
|
|
14
|
+
|
|
15
|
+
The animate test uses a deliberately huge ``interval`` so the background thread
|
|
16
|
+
is still parked in its first ``wait`` when ``__exit__`` sets the stop event —
|
|
17
|
+
that makes "exactly the synchronous frame 0 was painted" reliable rather than
|
|
18
|
+
racing the thread's clock.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import io
|
|
22
|
+
|
|
23
|
+
from fvpnctl import spinner
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FakeStream(io.StringIO):
|
|
27
|
+
"""A ``StringIO`` with a scripted ``isatty()`` so mode selection is testable."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, tty):
|
|
30
|
+
super().__init__()
|
|
31
|
+
self._tty = tty
|
|
32
|
+
|
|
33
|
+
def isatty(self):
|
|
34
|
+
return self._tty
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# -- pure helpers ------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_render_frame_carries_braille_message_and_elapsed():
|
|
41
|
+
out = spinner.render_frame("⠋", "Connecting profile apoz…", 7)
|
|
42
|
+
assert out.startswith("\r")
|
|
43
|
+
assert "⠋" in out
|
|
44
|
+
assert "Connecting profile apoz…" in out
|
|
45
|
+
assert "7s" in out
|
|
46
|
+
# Clears to end of line so a shrinking message never leaves stale tail chars.
|
|
47
|
+
assert out.endswith(spinner._CLEAR_EOL)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_select_mode_off_when_disabled_even_on_tty():
|
|
51
|
+
assert spinner.select_mode(FakeStream(tty=True), enabled=False) == "off"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_select_mode_static_on_non_tty():
|
|
55
|
+
assert spinner.select_mode(FakeStream(tty=False), enabled=True) == "static"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_select_mode_animate_on_tty():
|
|
59
|
+
assert spinner.select_mode(FakeStream(tty=True), enabled=True) == "animate"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# -- context manager: the three modes ----------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_off_mode_writes_nothing():
|
|
66
|
+
stream = FakeStream(tty=True)
|
|
67
|
+
with spinner.Spinner("Connecting profile apoz…", stream=stream, enabled=False):
|
|
68
|
+
pass
|
|
69
|
+
assert stream.getvalue() == ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_static_mode_writes_one_plain_line():
|
|
73
|
+
stream = FakeStream(tty=False)
|
|
74
|
+
with spinner.Spinner("Connecting profile apoz…", stream=stream, enabled=True):
|
|
75
|
+
pass
|
|
76
|
+
out = stream.getvalue()
|
|
77
|
+
assert out == "Connecting profile apoz…\n"
|
|
78
|
+
# No ANSI, no carriage-return animation in the pipe/log path.
|
|
79
|
+
assert "\x1b" not in out
|
|
80
|
+
assert "\r" not in out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_animate_mode_paints_frame0_and_cleans_up():
|
|
84
|
+
stream = FakeStream(tty=True)
|
|
85
|
+
# Huge interval: the worker thread parks in its first wait() and never paints
|
|
86
|
+
# a second frame before __exit__ stops it — so only the synchronous frame 0
|
|
87
|
+
# is guaranteed, making the assertions deterministic.
|
|
88
|
+
with spinner.Spinner("Connecting profile apoz…", stream=stream, enabled=True, interval=1000):
|
|
89
|
+
pass
|
|
90
|
+
out = stream.getvalue()
|
|
91
|
+
assert spinner._HIDE_CURSOR in out # cursor hidden on enter
|
|
92
|
+
assert spinner._FRAMES[0] in out # first braille frame painted synchronously
|
|
93
|
+
assert "Connecting profile apoz…" in out
|
|
94
|
+
assert spinner._SHOW_CURSOR in out # cursor restored on exit
|
|
95
|
+
# The line is wiped on exit so the next print starts clean.
|
|
96
|
+
assert out.rstrip().endswith(spinner._SHOW_CURSOR) or spinner._CLEAR_EOL in out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_animate_mode_restores_cursor_even_on_exception():
|
|
100
|
+
stream = FakeStream(tty=True)
|
|
101
|
+
try:
|
|
102
|
+
with spinner.Spinner(
|
|
103
|
+
"Connecting profile apoz…", stream=stream, enabled=True, interval=1000
|
|
104
|
+
):
|
|
105
|
+
raise RuntimeError("connect blew up")
|
|
106
|
+
except RuntimeError:
|
|
107
|
+
pass
|
|
108
|
+
out = stream.getvalue()
|
|
109
|
+
# A failed connect (ConnectTimeout etc.) must still leave a clean terminal.
|
|
110
|
+
assert spinner._SHOW_CURSOR in out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# -- progress bar: pure render ----------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_render_bar_fills_proportionally():
|
|
117
|
+
out = spinner.render_bar("Connecting profile apoz…", 6.0, 12.0)
|
|
118
|
+
assert out.startswith("\r")
|
|
119
|
+
assert "Connecting profile apoz…" in out
|
|
120
|
+
assert "50%" in out # halfway to the ETA
|
|
121
|
+
assert spinner._BAR_FILL in out and spinner._BAR_EMPTY in out
|
|
122
|
+
assert "6s / ~12s" in out
|
|
123
|
+
assert out.endswith(spinner._CLEAR_EOL)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_render_bar_caps_below_100_when_overdue():
|
|
127
|
+
out = spinner.render_bar("x", 50.0, 10.0) # 5x past the ETA
|
|
128
|
+
assert "99%" in out # capped, never 100% while still running
|
|
129
|
+
assert "100%" not in out
|
|
130
|
+
assert spinner._BAR_EMPTY in out # one empty cell remains until truly done
|
|
131
|
+
assert "50s / ~10s" in out # honest about the overrun
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_render_bar_handles_zero_eta_without_dividing():
|
|
135
|
+
out = spinner.render_bar("x", 1.0, 0.0)
|
|
136
|
+
assert "0%" in out # no ZeroDivisionError, just an empty bar
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# -- progress bar: context manager modes ------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_progress_bar_animate_paints_and_cleans_up():
|
|
143
|
+
stream = FakeStream(tty=True)
|
|
144
|
+
with spinner.ProgressBar(
|
|
145
|
+
"Connecting profile apoz…", 12.0, stream=stream, enabled=True, interval=1000
|
|
146
|
+
):
|
|
147
|
+
pass
|
|
148
|
+
out = stream.getvalue()
|
|
149
|
+
assert spinner._HIDE_CURSOR in out
|
|
150
|
+
assert spinner._BAR_EMPTY in out # the (mostly empty) bar was painted at t≈0
|
|
151
|
+
assert "Connecting profile apoz…" in out
|
|
152
|
+
assert spinner._SHOW_CURSOR in out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_progress_bar_off_writes_nothing():
|
|
156
|
+
stream = FakeStream(tty=True)
|
|
157
|
+
with spinner.ProgressBar("m", 5.0, stream=stream, enabled=False):
|
|
158
|
+
pass
|
|
159
|
+
assert stream.getvalue() == ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_progress_bar_static_writes_one_plain_line():
|
|
163
|
+
stream = FakeStream(tty=False)
|
|
164
|
+
with spinner.ProgressBar("Connecting profile apoz…", 5.0, stream=stream, enabled=True):
|
|
165
|
+
pass
|
|
166
|
+
out = stream.getvalue()
|
|
167
|
+
assert out == "Connecting profile apoz…\n"
|
|
168
|
+
assert "\x1b" not in out and "\r" not in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fvpnctl-0.2.2 → fvpnctl-0.2.4}/docs/superpowers/specs/2026-06-20-fortivpn-cli-via-cdp-design.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|