fvpnctl 0.2.2__tar.gz → 0.2.3__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.3}/PKG-INFO +4 -1
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/README.md +3 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/pyproject.toml +1 -1
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/__init__.py +1 -1
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/cli.py +19 -7
- fvpnctl-0.2.3/src/fvpnctl/spinner.py +156 -0
- fvpnctl-0.2.3/tests/test_spinner.py +110 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/uv.lock +1 -1
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/.github/workflows/ci.yml +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/.gitignore +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/.pre-commit-config.yaml +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/LICENSE +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/contrib/com.fvpnctl.headless.plist +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/docs/how-it-works.md +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/docs/superpowers/specs/2026-06-20-fortivpn-cli-via-cdp-design.md +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/cdp.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/controller.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/errors.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/keychain.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/launcher.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/monitor.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/manual/test_live.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_cdp.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_cli.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_controller.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_errors.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_keychain.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_launcher.py +0 -0
- {fvpnctl-0.2.2 → fvpnctl-0.2.3}/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.3
|
|
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
|
|
|
@@ -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
|
|
|
@@ -67,6 +67,7 @@ from fvpnctl import launcher, monitor
|
|
|
67
67
|
from fvpnctl.cdp import CDPSession
|
|
68
68
|
from fvpnctl.controller import FortiVPN
|
|
69
69
|
from fvpnctl.errors import CDPEvaluateError, FortiError, NotRunningError
|
|
70
|
+
from fvpnctl.spinner import Spinner
|
|
70
71
|
|
|
71
72
|
# IPsec ``ipsec_state`` value meaning "tunnel up" (docs/how-it-works.md section 2
|
|
72
73
|
# / design spec 4.2). The CLI only needs the CONNECTED sentinel; the controller
|
|
@@ -364,20 +365,31 @@ def _cmd_connect(fvpnctl: FortiVPN, args: argparse.Namespace) -> int:
|
|
|
364
365
|
and returns; otherwise, on a CONNECTED terminal state, it fetches the VPN IP
|
|
365
366
|
and prints ``CONNECTED <profile> <ip>``. The password is resolved inside the
|
|
366
367
|
controller (from the Keychain) and is never accepted or printed here.
|
|
368
|
+
|
|
369
|
+
The waited path blocks in the controller's once-a-second poll loop while the
|
|
370
|
+
daemon negotiates, which otherwise looks frozen — so it is wrapped in a
|
|
371
|
+
:class:`~fvpnctl.spinner.Spinner` that animates a Braille throbber on stderr
|
|
372
|
+
(a single static line when piped, nothing under ``--quiet``; gated by
|
|
373
|
+
``_VERBOSE`` exactly like :func:`report`). ``--no-wait`` returns immediately,
|
|
374
|
+
so it keeps the plain progress line instead.
|
|
367
375
|
"""
|
|
368
376
|
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
|
-
)
|
|
377
|
+
msg = f"Connecting profile {args.profile}…"
|
|
376
378
|
|
|
377
379
|
if not wait:
|
|
380
|
+
report(msg)
|
|
381
|
+
fvpnctl.connect(args.profile, username=args.user, wait=False, timeout=args.timeout)
|
|
378
382
|
print(f"connecting {args.profile} ...")
|
|
379
383
|
return 0
|
|
380
384
|
|
|
385
|
+
with Spinner(msg, stream=sys.stderr, enabled=_VERBOSE):
|
|
386
|
+
state = fvpnctl.connect(
|
|
387
|
+
args.profile,
|
|
388
|
+
username=args.user,
|
|
389
|
+
wait=True,
|
|
390
|
+
timeout=args.timeout,
|
|
391
|
+
)
|
|
392
|
+
|
|
381
393
|
if state.ipsec_state == _CONNECTED:
|
|
382
394
|
ip = fvpnctl.connection_ip(args.profile, _IPSEC)
|
|
383
395
|
vpn_ip = ip.get("vpn_ip", "")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Connect throbber — a Braille spinner for the blocking ``connect`` wait.
|
|
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 a live Braille spinner + elapsed-seconds counter so the user
|
|
10
|
+
can see the wait is *working*, not hung.
|
|
11
|
+
|
|
12
|
+
How it stays out of the controller's way
|
|
13
|
+
----------------------------------------
|
|
14
|
+
The poll loop lives in ``controller.py`` and must stay UI-agnostic (the same
|
|
15
|
+
controller/transport/render split the rest of the codebase follows — see
|
|
16
|
+
``monitor.py``). So the spinner does **not** reach into the loop: the CLI wraps
|
|
17
|
+
the blocking ``connect()`` call in a :class:`Spinner` context manager, the
|
|
18
|
+
spinner animates on a daemon thread while the main thread does the CDP I/O, and
|
|
19
|
+
``__exit__`` tears the animation down. The controller is untouched.
|
|
20
|
+
|
|
21
|
+
Three modes, chosen from the stream + verbosity (:func:`select_mode`)
|
|
22
|
+
---------------------------------------------------------------------
|
|
23
|
+
* **off** — ``--quiet`` (``enabled=False``): write nothing at all.
|
|
24
|
+
* **static** — verbose but the stream is not a TTY (piped / redirected): one
|
|
25
|
+
plain ``message`` line, no ANSI and no ``\\r`` spam, so logs stay readable.
|
|
26
|
+
* **animate**— verbose on a real TTY: hide the cursor, paint frame 0
|
|
27
|
+
synchronously (instant first feedback, and one deterministic frame for tests),
|
|
28
|
+
then animate the remaining frames on a background thread until ``__exit__``.
|
|
29
|
+
|
|
30
|
+
stderr, never stdout
|
|
31
|
+
--------------------
|
|
32
|
+
Like ``cli.report``, the spinner writes to **stderr** so stdout stays the
|
|
33
|
+
machine-readable channel (``CONNECTED <profile> <ip>``, ``--json``, pipelines).
|
|
34
|
+
``__exit__`` wipes the spinner line and restores the cursor *before* the caller
|
|
35
|
+
prints its result/error, so success and failure both start on a clean line.
|
|
36
|
+
|
|
37
|
+
Pure vs. I/O
|
|
38
|
+
------------
|
|
39
|
+
:func:`render_frame` and :func:`select_mode` are pure (data in, string out) and
|
|
40
|
+
unit-tested directly; only :class:`Spinner` touches a stream, a thread, and the
|
|
41
|
+
clock.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import itertools
|
|
45
|
+
import sys
|
|
46
|
+
import threading
|
|
47
|
+
import time
|
|
48
|
+
|
|
49
|
+
# Braille "dots" spinner — ten frames that read as a smooth rotation. Zero
|
|
50
|
+
# dependencies (project rule): the frames are just literal Unicode here.
|
|
51
|
+
_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
52
|
+
|
|
53
|
+
# ANSI control sequences (defined locally so this module stays standalone, like
|
|
54
|
+
# ``monitor.py`` keeps its own copy). Named so the I/O below reads as intent.
|
|
55
|
+
_HIDE_CURSOR = "\x1b[?25l"
|
|
56
|
+
_SHOW_CURSOR = "\x1b[?25h"
|
|
57
|
+
_CLEAR_EOL = "\x1b[K"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render_frame(frame: str, message: str, elapsed: int) -> str:
|
|
61
|
+
"""Build one spinner line: ``\\r<frame> <message> <elapsed>s`` + clear-to-EOL.
|
|
62
|
+
|
|
63
|
+
Leads with ``\\r`` so each frame overwrites the previous one in place, and
|
|
64
|
+
ends with :data:`_CLEAR_EOL` so a shrinking ``message`` (or a smaller elapsed
|
|
65
|
+
count) never leaves stale characters trailing on the line. Pure — no I/O.
|
|
66
|
+
"""
|
|
67
|
+
return f"\r{frame} {message} {elapsed:d}s{_CLEAR_EOL}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def select_mode(stream, *, enabled: bool) -> str:
|
|
71
|
+
"""Choose ``off``/``static``/``animate`` from verbosity + the stream.
|
|
72
|
+
|
|
73
|
+
``enabled`` is the CLI's verbose flag: ``False`` (``--quiet``) → ``off``
|
|
74
|
+
regardless of the stream. Otherwise a real TTY animates and anything else
|
|
75
|
+
(a pipe, a file, a captured test buffer) gets the single static line.
|
|
76
|
+
"""
|
|
77
|
+
if not enabled:
|
|
78
|
+
return "off"
|
|
79
|
+
isatty = getattr(stream, "isatty", None)
|
|
80
|
+
if isatty and isatty():
|
|
81
|
+
return "animate"
|
|
82
|
+
return "static"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Spinner:
|
|
86
|
+
"""Context manager that animates a Braille throbber around a blocking call.
|
|
87
|
+
|
|
88
|
+
Usage::
|
|
89
|
+
|
|
90
|
+
with Spinner("Connecting profile apoz…", stream=sys.stderr, enabled=verbose):
|
|
91
|
+
state = fvpnctl.connect(...) # blocks; the spinner animates meanwhile
|
|
92
|
+
|
|
93
|
+
The mode is fixed at construction from ``stream`` + ``enabled`` (see
|
|
94
|
+
:func:`select_mode`). In ``animate`` mode a daemon thread paints a frame
|
|
95
|
+
every ``interval`` seconds; ``__exit__`` stops it, joins it, wipes the line
|
|
96
|
+
and restores the cursor — including when the wrapped block raises (a
|
|
97
|
+
``ConnectTimeout``/``ConnectFailed`` must still leave a clean terminal).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, message: str, *, stream=None, enabled: bool = True, interval: float = 0.1):
|
|
101
|
+
self._message = message
|
|
102
|
+
self._stream = stream if stream is not None else sys.stderr
|
|
103
|
+
self._interval = interval
|
|
104
|
+
self._mode = select_mode(self._stream, enabled=enabled)
|
|
105
|
+
self._stop = threading.Event()
|
|
106
|
+
self._thread: threading.Thread | None = None
|
|
107
|
+
self._start: float | None = None
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> "Spinner":
|
|
110
|
+
if self._mode == "off":
|
|
111
|
+
return self
|
|
112
|
+
if self._mode == "static":
|
|
113
|
+
# Pipe/log path: one plain line, no animation. Mirrors the old
|
|
114
|
+
# ``report("Connecting profile …")`` behaviour exactly.
|
|
115
|
+
self._stream.write(self._message + "\n")
|
|
116
|
+
self._stream.flush()
|
|
117
|
+
return self
|
|
118
|
+
# animate: hide the cursor and paint the first frame right away so there
|
|
119
|
+
# is instant feedback (and one deterministic frame for the tests) before
|
|
120
|
+
# the background thread takes over.
|
|
121
|
+
self._start = time.monotonic()
|
|
122
|
+
self._stream.write(_HIDE_CURSOR)
|
|
123
|
+
self._paint(_FRAMES[0])
|
|
124
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
125
|
+
self._thread.start()
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def _paint(self, frame: str) -> None:
|
|
129
|
+
"""Write one frame for the current elapsed time, then flush."""
|
|
130
|
+
elapsed = int(time.monotonic() - self._start) if self._start is not None else 0
|
|
131
|
+
self._stream.write(render_frame(frame, self._message, elapsed))
|
|
132
|
+
self._stream.flush()
|
|
133
|
+
|
|
134
|
+
def _run(self) -> None:
|
|
135
|
+
"""Background loop: advance the frame every ``interval`` until stopped.
|
|
136
|
+
|
|
137
|
+
Uses ``Event.wait(interval)`` rather than ``time.sleep`` so ``__exit__``
|
|
138
|
+
setting the stop event ends the wait *immediately* — the spinner never
|
|
139
|
+
delays the caller by up to ``interval`` on teardown.
|
|
140
|
+
"""
|
|
141
|
+
for frame in itertools.cycle(_FRAMES[1:] + _FRAMES[:1]):
|
|
142
|
+
if self._stop.wait(self._interval):
|
|
143
|
+
return
|
|
144
|
+
self._paint(frame)
|
|
145
|
+
|
|
146
|
+
def __exit__(self, *exc) -> bool:
|
|
147
|
+
if self._mode != "animate":
|
|
148
|
+
return False
|
|
149
|
+
self._stop.set()
|
|
150
|
+
if self._thread is not None:
|
|
151
|
+
self._thread.join()
|
|
152
|
+
# Wipe the spinner line and bring the cursor back so the caller's next
|
|
153
|
+
# write (result on stdout / error on stderr) starts on a clean line.
|
|
154
|
+
self._stream.write("\r" + _CLEAR_EOL + _SHOW_CURSOR)
|
|
155
|
+
self._stream.flush()
|
|
156
|
+
return False # never suppress the wrapped block's exception
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
|
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.3}/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
|
|
File without changes
|