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.
Files changed (29) hide show
  1. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/PKG-INFO +4 -1
  2. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/README.md +3 -0
  3. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/pyproject.toml +1 -1
  4. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/__init__.py +1 -1
  5. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/cli.py +19 -7
  6. fvpnctl-0.2.3/src/fvpnctl/spinner.py +156 -0
  7. fvpnctl-0.2.3/tests/test_spinner.py +110 -0
  8. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/uv.lock +1 -1
  9. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/.github/workflows/ci.yml +0 -0
  10. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/.gitignore +0 -0
  11. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/.pre-commit-config.yaml +0 -0
  12. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/LICENSE +0 -0
  13. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/contrib/com.fvpnctl.headless.plist +0 -0
  14. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/docs/how-it-works.md +0 -0
  15. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/docs/superpowers/specs/2026-06-20-fortivpn-cli-via-cdp-design.md +0 -0
  16. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/cdp.py +0 -0
  17. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/controller.py +0 -0
  18. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/errors.py +0 -0
  19. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/keychain.py +0 -0
  20. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/launcher.py +0 -0
  21. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/src/fvpnctl/monitor.py +0 -0
  22. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/manual/test_live.py +0 -0
  23. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_cdp.py +0 -0
  24. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_cli.py +0 -0
  25. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_controller.py +0 -0
  26. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_errors.py +0 -0
  27. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_keychain.py +0 -0
  28. {fvpnctl-0.2.2 → fvpnctl-0.2.3}/tests/test_launcher.py +0 -0
  29. {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.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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fvpnctl"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  requires-python = ">=3.11"
5
5
  readme = "README.md"
6
6
  description = "Control your FortiClient VPN from the macOS command line."
@@ -1,3 +1,3 @@
1
1
  """fvpnctl — control FortiClient IPsec VPN on macOS via the Chrome DevTools Protocol."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -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
- report(f"Connecting profile {args.profile}…")
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
@@ -12,7 +12,7 @@ supported-markers = [
12
12
 
13
13
  [[package]]
14
14
  name = "fvpnctl"
15
- version = "0.2.2"
15
+ version = "0.2.3"
16
16
  source = { editable = "." }
17
17
 
18
18
  [package.dev-dependencies]
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